thwip-cli 1.6.2__tar.gz → 1.8.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 (66) hide show
  1. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/PKG-INFO +43 -1
  2. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/README.md +42 -0
  3. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/docs/verification.md +5 -2
  4. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/pyproject.toml +1 -1
  5. thwip_cli-1.8.0/tests/test_memory.py +210 -0
  6. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/tests/test_native_agents.py +73 -1
  7. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/tests/test_native_print.py +50 -0
  8. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/tests/test_parity_commands.py +27 -0
  9. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/tests/test_session.py +21 -0
  10. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/__init__.py +1 -1
  11. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/agents/base.py +2 -0
  12. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/agents/native_agent.py +64 -25
  13. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/agents/native_common.py +25 -0
  14. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/agents/native_print.py +36 -9
  15. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/cli.py +296 -9
  16. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/config.py +31 -0
  17. thwip_cli-1.8.0/thwip/data/context_template.md +44 -0
  18. thwip_cli-1.8.0/thwip/data/thwip.1 +133 -0
  19. thwip_cli-1.8.0/thwip/memory.py +335 -0
  20. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/session.py +37 -1
  21. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/shortcuts.py +3 -0
  22. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/theme.py +1 -0
  23. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/uv.lock +1 -1
  24. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/.github/workflows/publish.yml +0 -0
  25. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/.gitignore +0 -0
  26. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/LICENSE +0 -0
  27. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/docs/handoff-research.md +0 -0
  28. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/install.sh +0 -0
  29. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/tests/__init__.py +0 -0
  30. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/tests/fake_providers.py +0 -0
  31. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/tests/test_agents.py +0 -0
  32. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/tests/test_audit_regressions.py +0 -0
  33. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/tests/test_catalog.py +0 -0
  34. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/tests/test_cli.py +0 -0
  35. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/tests/test_compatible_streaming.py +0 -0
  36. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/tests/test_config.py +0 -0
  37. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/tests/test_detector.py +0 -0
  38. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/tests/test_direct_providers_e2e.py +0 -0
  39. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/tests/test_handoff.py +0 -0
  40. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/tests/test_native_launcher.py +0 -0
  41. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/tests/test_native_rpc.py +0 -0
  42. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/tests/test_repair_verification.py +0 -0
  43. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/tests/test_tools.py +0 -0
  44. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/tests/test_utils.py +0 -0
  45. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/__main__.py +0 -0
  46. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/agents/__init__.py +0 -0
  47. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/agents/catalog.py +0 -0
  48. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/agents/chat_messages.py +0 -0
  49. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/agents/claude_agent.py +0 -0
  50. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/agents/deepseek_agent.py +0 -0
  51. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/agents/google_agent.py +0 -0
  52. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/agents/groq_agent.py +0 -0
  53. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/agents/native_rpc.py +0 -0
  54. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/agents/ollama_agent.py +0 -0
  55. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/agents/openai_agent.py +0 -0
  56. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/agents/openrouter_agent.py +0 -0
  57. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/detector.py +0 -0
  58. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/endpoints.py +0 -0
  59. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/handoff.py +0 -0
  60. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/limits.py +0 -0
  61. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/tools/__init__.py +0 -0
  62. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/tools/code_runner.py +0 -0
  63. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/tools/file_editor.py +0 -0
  64. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/tools/git_ops.py +0 -0
  65. {thwip_cli-1.6.2 → thwip_cli-1.8.0}/thwip/tools/terminal.py +0 -0
  66. {thwip_cli-1.6.2 → thwip_cli-1.8.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.8.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
@@ -55,6 +55,7 @@ Description-Content-Type: text/markdown
55
55
  - **Tool Layer**: Shared file editing, code execution (Python, Node, Shell), and Git integration across connected models.
56
56
  - **Session Persistence**: Save and resume sessions across projects with `/session save` and `/session load`.
57
57
  - **Familiar Flow**: The same everyday commands as Codex, Claude Code, and Antigravity (`/model`, `/new`, `/resume`, `/compact`, `/diff`, `/copy`, `!cmd`, `@file`), plus autocompletion, Ctrl+S / Ctrl+T shortcuts, and streaming Markdown.
58
+ - **Project Memory and Second Brain**: One `context.md` per project that every agent reads and maintains, filed into your Obsidian vault with cross-project links by stack, area, and tag.
58
59
  - **Live Model Lists**: Model catalogs come from the connected CLI or the provider's list-models endpoint, never from a hardcoded list.
59
60
 
60
61
  ---
@@ -85,6 +86,8 @@ Start the interactive terminal in your current project directory:
85
86
  thwip
86
87
  thwip --project ~/code/my-app # open a specific project
87
88
  thwip --version
89
+ thwip man # read the manual page
90
+ thwip install-man # then `man thwip` works
88
91
  ```
89
92
 
90
93
  ---
@@ -142,6 +145,16 @@ not in the catalog is passed to the CLI for validation instead of being rejected
142
145
  a bundled list. A model available in a desktop app may still be absent from the
143
146
  installed CLI's catalog or account access.
144
147
 
148
+ Each provider keeps a warm native session. The first message to a provider sends the
149
+ portable conversation once; after that only new messages are sent. Codex keeps one
150
+ `app-server` process running and resumes its thread; Claude Code uses `--session-id`
151
+ and `--resume`; Antigravity uses `--conversation`. When you come back to a provider after
152
+ chatting with another one, it receives a short catch-up block containing only what it
153
+ missed. The native session IDs are stored in the thwip session file next to the
154
+ transcript, validated on load, shown by `/status`, and cleared by `/new`, `/clear`,
155
+ `/compact`, and a project change. If a CLI can no longer continue a session, thwip says
156
+ so and resends the full conversation to a fresh one.
157
+
145
158
  Native connections are read-only by default. Codex starts with a read-only sandbox
146
159
  and asks before operations outside it; approval requests appear in Thwip with the
147
160
  command or file list and default to denial. Claude Code and the Antigravity CLI run
@@ -163,6 +176,35 @@ replaces Thwip with the Codex CLI itself in the selected project. Conversation
163
176
  history is not transferred by the launcher; use `/session load` after restarting
164
177
  Thwip to resume.
165
178
 
179
+ ## Project memory and your second brain
180
+
181
+ Every project gets one canonical memory file, `context.md` in the project root, that
182
+ every agent you use through thwip reads and helps maintain. It records the snapshot,
183
+ current work (Now, Blocked, Next), architecture notes, decisions, known issues, and
184
+ dated recent changes. Whichever provider you switch to receives it as instructions, so
185
+ the project's state survives agent switches and sessions, not just the conversation.
186
+
187
+ On first run thwip offers to connect a second-brain vault: it detects vaults registered
188
+ with the Obsidian desktop app, or creates a new Markdown folder. In the vault each
189
+ project gets a stable index card under `Projects/`, hub notes under `Stack/`, `Areas/`,
190
+ and `Tags/`, and a `Projects.md` dashboard. Projects that share a stack, area, or tag
191
+ link to each other through those hubs and a "Related projects" list on every card.
192
+ thwip only writes notes it created itself (marked `generated_by: thwip`); hand-written
193
+ notes are never touched.
194
+
195
+ | Command | Action |
196
+ |:---|:---|
197
+ | `/memory` | Show this project's memory file |
198
+ | `/memory init [area]` | Create it from the template with detected stack and entry points |
199
+ | `/memory update` | Ask the current model to revise it from the conversation; a diff is shown and written only after you confirm |
200
+ | `/memory edit` | Open it in `$EDITOR` |
201
+ | `/memory sync` | File the project into the vault and refresh hubs and the dashboard |
202
+ | `/memory vault <path>` | Connect or change the vault |
203
+ | `/memory link` | Add a pointer to `AGENTS.md` and `CLAUDE.md` so the CLIs read it outside thwip too |
204
+
205
+ When you `/quit` after a real conversation, thwip offers the update once. Settings live under
206
+ `[memory]` in `~/.thwip/config.toml`: `enabled`, `file`, `vault`, `offer_update_on_quit`.
207
+
166
208
  ## Live model lists
167
209
 
168
210
  Model lists are not hardcoded. Each connected source supplies its own list:
@@ -18,6 +18,7 @@
18
18
  - **Tool Layer**: Shared file editing, code execution (Python, Node, Shell), and Git integration across connected models.
19
19
  - **Session Persistence**: Save and resume sessions across projects with `/session save` and `/session load`.
20
20
  - **Familiar Flow**: The same everyday commands as Codex, Claude Code, and Antigravity (`/model`, `/new`, `/resume`, `/compact`, `/diff`, `/copy`, `!cmd`, `@file`), plus autocompletion, Ctrl+S / Ctrl+T shortcuts, and streaming Markdown.
21
+ - **Project Memory and Second Brain**: One `context.md` per project that every agent reads and maintains, filed into your Obsidian vault with cross-project links by stack, area, and tag.
21
22
  - **Live Model Lists**: Model catalogs come from the connected CLI or the provider's list-models endpoint, never from a hardcoded list.
22
23
 
23
24
  ---
@@ -48,6 +49,8 @@ Start the interactive terminal in your current project directory:
48
49
  thwip
49
50
  thwip --project ~/code/my-app # open a specific project
50
51
  thwip --version
52
+ thwip man # read the manual page
53
+ thwip install-man # then `man thwip` works
51
54
  ```
52
55
 
53
56
  ---
@@ -105,6 +108,16 @@ not in the catalog is passed to the CLI for validation instead of being rejected
105
108
  a bundled list. A model available in a desktop app may still be absent from the
106
109
  installed CLI's catalog or account access.
107
110
 
111
+ Each provider keeps a warm native session. The first message to a provider sends the
112
+ portable conversation once; after that only new messages are sent. Codex keeps one
113
+ `app-server` process running and resumes its thread; Claude Code uses `--session-id`
114
+ and `--resume`; Antigravity uses `--conversation`. When you come back to a provider after
115
+ chatting with another one, it receives a short catch-up block containing only what it
116
+ missed. The native session IDs are stored in the thwip session file next to the
117
+ transcript, validated on load, shown by `/status`, and cleared by `/new`, `/clear`,
118
+ `/compact`, and a project change. If a CLI can no longer continue a session, thwip says
119
+ so and resends the full conversation to a fresh one.
120
+
108
121
  Native connections are read-only by default. Codex starts with a read-only sandbox
109
122
  and asks before operations outside it; approval requests appear in Thwip with the
110
123
  command or file list and default to denial. Claude Code and the Antigravity CLI run
@@ -126,6 +139,35 @@ replaces Thwip with the Codex CLI itself in the selected project. Conversation
126
139
  history is not transferred by the launcher; use `/session load` after restarting
127
140
  Thwip to resume.
128
141
 
142
+ ## Project memory and your second brain
143
+
144
+ Every project gets one canonical memory file, `context.md` in the project root, that
145
+ every agent you use through thwip reads and helps maintain. It records the snapshot,
146
+ current work (Now, Blocked, Next), architecture notes, decisions, known issues, and
147
+ dated recent changes. Whichever provider you switch to receives it as instructions, so
148
+ the project's state survives agent switches and sessions, not just the conversation.
149
+
150
+ On first run thwip offers to connect a second-brain vault: it detects vaults registered
151
+ with the Obsidian desktop app, or creates a new Markdown folder. In the vault each
152
+ project gets a stable index card under `Projects/`, hub notes under `Stack/`, `Areas/`,
153
+ and `Tags/`, and a `Projects.md` dashboard. Projects that share a stack, area, or tag
154
+ link to each other through those hubs and a "Related projects" list on every card.
155
+ thwip only writes notes it created itself (marked `generated_by: thwip`); hand-written
156
+ notes are never touched.
157
+
158
+ | Command | Action |
159
+ |:---|:---|
160
+ | `/memory` | Show this project's memory file |
161
+ | `/memory init [area]` | Create it from the template with detected stack and entry points |
162
+ | `/memory update` | Ask the current model to revise it from the conversation; a diff is shown and written only after you confirm |
163
+ | `/memory edit` | Open it in `$EDITOR` |
164
+ | `/memory sync` | File the project into the vault and refresh hubs and the dashboard |
165
+ | `/memory vault <path>` | Connect or change the vault |
166
+ | `/memory link` | Add a pointer to `AGENTS.md` and `CLAUDE.md` so the CLIs read it outside thwip too |
167
+
168
+ When you `/quit` after a real conversation, thwip offers the update once. Settings live under
169
+ `[memory]` in `~/.thwip/config.toml`: `enabled`, `file`, `vault`, `offer_update_on_quit`.
170
+
129
171
  ## Live model lists
130
172
 
131
173
  Model lists are not hardcoded. Each connected source supplies its own list:
@@ -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.8.0. The Python suite
4
+ passes 282 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,9 @@ 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
+ | Project memory and vault (v1.8.0) | Live in a temp project with a temp config and vault: first-run onboarding listed the real Obsidian vault and accepted a typed path; `/memory init` detected the stack and filed card, hub, and dashboard notes; a Codex turn received the memory as instructions; `/memory update` produced a diff written only after `y`; `/status` and the quit-time offer behaved. Offline: frontmatter, template init, injection bounds, Obsidian registry detection, vault sync with related links and hand-written-note protection, update flow, invalid model output rejection, onboarding choices |
23
+ | 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 |
24
+ | Manual page (v1.7.0) | `thwip install-man` then `man -w thwip` and `man thwip` resolve from `~/.local/share/man` |
22
25
  | 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
26
  | 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
27
  | 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.8.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"
@@ -0,0 +1,210 @@
1
+ """Project memory file, vault filing with cross-project links, onboarding, and model-driven updates."""
2
+
3
+ import json
4
+
5
+ import pytest
6
+
7
+ from thwip.agents.base import AgentDone, TextDelta
8
+ from thwip.cli import ThwipCLI
9
+ from thwip.config import ThwipConfig
10
+ from thwip.memory import ProjectMemory, Vault, detect_obsidian_vaults, parse_frontmatter, render_frontmatter
11
+ from thwip.session import Session
12
+ from thwip.tools import ToolManager
13
+
14
+
15
+ def test_frontmatter_round_trip():
16
+ data, body = parse_frontmatter("---\nproject: Demo\nstack: [Python, Docker]\nupdated: 2026-09-24\n---\n\n# Demo\n")
17
+ assert data == {"project": "Demo", "stack": ["Python", "Docker"], "updated": "2026-09-24"} and body.startswith("# Demo")
18
+ assert render_frontmatter({"a": "x", "b": ["1", "2"], "c": ""}) == "---\na: x\nb: [1, 2]\n---"
19
+ assert parse_frontmatter("no frontmatter") == ({}, "no frontmatter")
20
+
21
+
22
+ def test_init_detects_stack_and_never_overwrites(tmp_path):
23
+ (tmp_path / "pyproject.toml").write_text("[project]\nname='demo'\n")
24
+ (tmp_path / "package.json").write_text("{}")
25
+ memory = ProjectMemory(str(tmp_path))
26
+ text = memory.init(area="Developer Tools", tags=["cli"])
27
+ data, body = parse_frontmatter(text)
28
+ assert data["project"] == tmp_path.name and data["stack"] == ["Python", "JavaScript"] and data["area"] == "Developer Tools"
29
+ assert data["type"] == "cli-tool" and data["tags"] == ["cli"] and "## Current Work" in body
30
+ memory.write("---\nproject: Custom\n---\n\n# Custom\n")
31
+ assert memory.init() .startswith("---\nproject: Custom"), "init never replaces an existing file"
32
+ assert "Project memory (context.md)" in memory.injection() and "# Custom" in memory.injection()
33
+ memory.write("---\nproject: Big\n---\n" + "x" * 20000)
34
+ assert memory.injection().endswith("[Project memory truncated; read the full file with /memory]")
35
+ assert memory.touch_updated("---\nupdated: 2000-01-01\nproject: Big\n---\n\nbody").split("\n")[1] != "updated: 2000-01-01"
36
+
37
+
38
+ def test_detect_obsidian_vaults_reads_registry(tmp_path, monkeypatch):
39
+ registry = tmp_path / "Library" / "Application Support" / "obsidian" / "obsidian.json"
40
+ registry.parent.mkdir(parents=True)
41
+ vault = tmp_path / "MyVault"
42
+ vault.mkdir()
43
+ registry.write_text(json.dumps({"vaults": {"a": {"path": str(vault)}, "b": {"path": str(tmp_path / "missing")}}}))
44
+ monkeypatch.setattr("thwip.memory.Path.home", lambda: tmp_path)
45
+ assert detect_obsidian_vaults() == [str(vault)]
46
+
47
+
48
+ def test_vault_sync_files_cards_hubs_and_related_links(tmp_path):
49
+ vault = Vault(str(tmp_path / "brain"))
50
+ vault.create()
51
+ alpha = tmp_path / "alpha"
52
+ beta = tmp_path / "beta"
53
+ for folder, extra in ((alpha, "Rust"), (beta, "Go")):
54
+ folder.mkdir()
55
+ ProjectMemory(str(folder)).write(
56
+ f"---\nproject: {folder.name}\npurpose: {folder.name} tool\narea: Developer Tools\nstatus: active\n"
57
+ f"updated: 2026-09-24\nstack: [Python, {extra}]\ntags: [cli]\n---\n\n# {folder.name} Context\n")
58
+ report = vault.sync(ProjectMemory(str(alpha)))
59
+ assert any(path.endswith("Projects/alpha.md") for path in report["written"])
60
+ card = (vault.root / "Projects" / "alpha.md").read_text()
61
+ assert "generated_by: thwip" in card and "None yet" in card
62
+ report = vault.sync(ProjectMemory(str(beta)))
63
+ beta_card = (vault.root / "Projects" / "beta.md").read_text()
64
+ assert "[[Projects/alpha|alpha]]: shares area Developer Tools, stack Python, tag cli" in beta_card
65
+ hub = (vault.root / "Stack" / "Python.md").read_text()
66
+ assert "[[Projects/alpha|alpha]]" in hub and "[[Projects/beta|beta]]" in hub
67
+ assert (vault.root / "Areas" / "Developer Tools.md").is_file() and (vault.root / "Tags" / "cli.md").is_file()
68
+ dashboard = (vault.root / "Projects.md").read_text()
69
+ assert "| [[Projects/alpha|alpha]] | Developer Tools | active | Python, Rust | 2026-09-24 |" in dashboard
70
+ # Re-filing alpha now links back to beta.
71
+ vault.sync(ProjectMemory(str(alpha)))
72
+ assert "[[Projects/beta|beta]]" in (vault.root / "Projects" / "alpha.md").read_text()
73
+ # A hand-written note is never overwritten.
74
+ (vault.root / "Projects" / "gamma.md").write_text("# my own note\n")
75
+ gamma = tmp_path / "gamma"
76
+ gamma.mkdir()
77
+ ProjectMemory(str(gamma)).write("---\nproject: gamma\n---\n\n# gamma\n")
78
+ report = vault.sync(ProjectMemory(str(gamma)))
79
+ assert report["skipped"] == [str(vault.root / "Projects" / "gamma.md")]
80
+ assert (vault.root / "Projects" / "gamma.md").read_text() == "# my own note\n"
81
+
82
+
83
+ class FakeAgent:
84
+ name = "openai"
85
+ display_name = "Fake"
86
+ company = "OpenAI"
87
+ native_tools = False
88
+
89
+ def __init__(self, reply):
90
+ self.reply = reply
91
+ self.prompts = []
92
+
93
+ def is_configured(self):
94
+ return True
95
+
96
+ async def chat(self, **kwargs):
97
+ self.prompts.append(kwargs)
98
+ yield TextDelta(content=self.reply)
99
+ yield AgentDone()
100
+
101
+
102
+ @pytest.fixture
103
+ def cli(tmp_path, monkeypatch):
104
+ monkeypatch.setenv("THWIP_CONFIG_DIR", str(tmp_path / "config"))
105
+ cli = ThwipCLI.__new__(ThwipCLI)
106
+ cli.config = ThwipConfig(project=str(tmp_path / "proj"), auto_save=False)
107
+ (tmp_path / "proj").mkdir()
108
+ cli.config.memory.vault = str(tmp_path / "brain")
109
+ cli.config.memory.onboarded = True
110
+ cli.tool_manager = ToolManager(str(tmp_path / "proj"))
111
+ cli.session = Session(project_path=str(tmp_path / "proj"), current_agent="openai", current_model="m")
112
+ cli.session.add_user_message("we decided to use sqlite")
113
+ cli.session.add_assistant_message("noted", agent_name="openai", model="m")
114
+ return cli
115
+
116
+
117
+ @pytest.mark.asyncio
118
+ async def test_memory_update_writes_after_confirmation_and_files_in_vault(cli, monkeypatch):
119
+ memory = cli._memory()
120
+ memory.init(area="Data")
121
+ proposed = memory.read().replace("- Now:", "- Now: use sqlite for storage").replace("## Decisions\n", "## Decisions\n\n- SQLite chosen for zero-setup storage.\n")
122
+ cli.current_agent = FakeAgent("```markdown\n" + proposed + "\n```")
123
+ answers = iter(["n", "y"])
124
+
125
+ async def ask(question):
126
+ return next(answers)
127
+ monkeypatch.setattr(cli, "_ask_text", ask)
128
+ assert await cli._memory_update() is False and "sqlite" not in memory.read()
129
+ assert await cli._memory_update() is True
130
+ text = memory.read()
131
+ assert "- Now: use sqlite for storage" in text and "SQLite chosen" in text
132
+ assert "we decided to use sqlite" in cli.current_agent.prompts[0]["messages"][0]["content"]
133
+ assert (tmp_path_card := cli._vault().root / "Projects" / (memory.name() + ".md")).is_file(), tmp_path_card
134
+ # Instructions sent to agents include the memory file.
135
+ assert "Project memory (context.md)" in cli._system_prompt_with_memory() and "SQLite chosen" in cli._system_prompt_with_memory()
136
+
137
+
138
+ @pytest.mark.asyncio
139
+ async def test_memory_update_rejects_invalid_model_output(cli, monkeypatch):
140
+ cli._memory().init()
141
+ before = cli._memory().read()
142
+ cli.current_agent = FakeAgent("Sure! Here is a summary instead of a file.")
143
+ assert await cli._memory_update() is False and cli._memory().read() == before
144
+
145
+
146
+ @pytest.mark.asyncio
147
+ async def test_memory_commands_show_init_vault_link(cli, monkeypatch, tmp_path):
148
+ await cli.cmd_memory("show")
149
+ await cli.cmd_memory("init", "Developer Tools")
150
+ assert cli._memory().frontmatter()["area"] == "Developer Tools"
151
+ await cli.cmd_memory("vault", str(tmp_path / "other-brain"))
152
+ assert cli.config.memory.vault == str((tmp_path / "other-brain").resolve())
153
+ assert (tmp_path / "other-brain" / "Projects.md").is_file()
154
+ cli._memory_link()
155
+ assert "context.md" in (tmp_path / "proj" / "AGENTS.md").read_text() and "context.md" in (tmp_path / "proj" / "CLAUDE.md").read_text()
156
+ cli._memory_link() # idempotent
157
+ assert (tmp_path / "proj" / "AGENTS.md").read_text().count("context.md") == 1
158
+
159
+
160
+ @pytest.mark.asyncio
161
+ async def test_onboarding_offers_detected_vault_and_saves_choice(cli, monkeypatch, tmp_path):
162
+ cli.config.memory.onboarded = False
163
+ cli.config.memory.vault = ""
164
+ existing = tmp_path / "ObsidianVault"
165
+ existing.mkdir()
166
+ monkeypatch.setattr("thwip.cli.detect_obsidian_vaults", lambda: [str(existing)])
167
+ monkeypatch.setattr("thwip.cli.sys.stdin.isatty", lambda: True)
168
+ from pathlib import Path
169
+ monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path / "home"))
170
+
171
+ async def choose_first(question):
172
+ return "1"
173
+ monkeypatch.setattr(cli, "_ask_text", choose_first)
174
+ await cli._memory_onboarding()
175
+ assert cli.config.memory.vault == str(existing) and cli.config.memory.onboarded
176
+ # Second start: no question asked again.
177
+ async def boom(question):
178
+ raise AssertionError("should not ask again")
179
+ monkeypatch.setattr(cli, "_ask_text", boom)
180
+ await cli._memory_onboarding()
181
+ # Skip choice leaves memory enabled but no vault.
182
+ cli.config.memory.onboarded = False
183
+ async def skip(question):
184
+ return "3"
185
+ monkeypatch.setattr(cli, "_ask_text", skip)
186
+ await cli._memory_onboarding()
187
+ assert cli.config.memory.vault == "" and cli.config.memory.onboarded
188
+ # Creating the default new vault lands under the (patched) home directory.
189
+ cli.config.memory.onboarded = False
190
+ async def create(question):
191
+ return "2"
192
+ monkeypatch.setattr(cli, "_ask_text", create)
193
+ await cli._memory_onboarding()
194
+ assert cli.config.memory.vault == str(tmp_path / "home" / "thwip-brain") and (tmp_path / "home" / "thwip-brain" / "Projects").is_dir()
195
+
196
+
197
+ @pytest.mark.asyncio
198
+ async def test_quit_offer_only_with_real_conversation(cli, monkeypatch):
199
+ asked = []
200
+ async def ask(question):
201
+ asked.append(question)
202
+ return "n"
203
+ monkeypatch.setattr(cli, "_ask_text", ask)
204
+ monkeypatch.setattr("thwip.cli.sys.stdin.isatty", lambda: True)
205
+ cli.current_agent = FakeAgent("x")
206
+ await cli._offer_memory_update("quitting")
207
+ assert len(asked) == 1 and "before quitting" in asked[0]
208
+ cli.session.clear_context()
209
+ await cli._offer_memory_update("quitting")
210
+ assert len(asked) == 1
@@ -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.8.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