pari-cli 0.1.3__tar.gz → 0.1.4__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 (108) hide show
  1. pari_cli-0.1.4/PKG-INFO +111 -0
  2. pari_cli-0.1.4/README.md +68 -0
  3. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/__init__.py +1 -1
  4. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/agent/guards.py +2 -0
  5. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/agent/loop.py +18 -3
  6. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/agent/runtime.py +48 -8
  7. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/auth/account.py +7 -6
  8. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/auth/interactive.py +62 -2
  9. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/auth/login.py +49 -0
  10. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/auth/menu.py +2 -0
  11. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/cli.py +108 -21
  12. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/edit/exact.py +5 -1
  13. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/providers/__init__.py +4 -4
  14. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/providers/deepseek.py +1 -2
  15. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/providers/openai_compat.py +22 -6
  16. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/sandbox/e2b.py +1 -1
  17. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/tools/bash.py +10 -2
  18. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/tools/registry.py +10 -2
  19. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/tools/search.py +26 -2
  20. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/tui/app.py +83 -2
  21. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/tui/prompt.py +9 -1
  22. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/tui/slash.py +2 -6
  23. pari_cli-0.1.4/pari_cli.egg-info/PKG-INFO +111 -0
  24. {pari_cli-0.1.3 → pari_cli-0.1.4}/pyproject.toml +1 -1
  25. pari_cli-0.1.3/PKG-INFO +0 -175
  26. pari_cli-0.1.3/README.md +0 -132
  27. pari_cli-0.1.3/pari_cli.egg-info/PKG-INFO +0 -175
  28. {pari_cli-0.1.3 → pari_cli-0.1.4}/LICENSE +0 -0
  29. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/__main__.py +0 -0
  30. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/agent/__init__.py +0 -0
  31. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/agent/lint.py +0 -0
  32. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/agent/reflection.py +0 -0
  33. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/agent/subagent.py +0 -0
  34. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/auth/__init__.py +0 -0
  35. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/auth/providers.py +0 -0
  36. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/auth/store.py +0 -0
  37. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/config.py +0 -0
  38. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/context/__init__.py +0 -0
  39. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/context/cache.py +0 -0
  40. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/context/chunks.py +0 -0
  41. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/context/compaction.py +0 -0
  42. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/context/epoch.py +0 -0
  43. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/context/warmer.py +0 -0
  44. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/edit/__init__.py +0 -0
  45. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/edit/cascade.py +0 -0
  46. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/edit/diff.py +0 -0
  47. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/edit/dmp.py +0 -0
  48. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/edit/git_osr.py +0 -0
  49. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/edit/indenter.py +0 -0
  50. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/eval/__init__.py +0 -0
  51. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/eval/judge.py +0 -0
  52. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/eval/logging.py +0 -0
  53. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/eval/metrics.py +0 -0
  54. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/eval/runner.py +0 -0
  55. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/fs/__init__.py +0 -0
  56. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/fs/atomic.py +0 -0
  57. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/fs/path.py +0 -0
  58. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/fs/queue.py +0 -0
  59. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/intelligence/__init__.py +0 -0
  60. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/intelligence/ast.py +0 -0
  61. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/intelligence/cache.py +0 -0
  62. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/intelligence/ppr.py +0 -0
  63. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/intelligence/repomap.py +0 -0
  64. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/intelligence/search.py +0 -0
  65. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/output/__init__.py +0 -0
  66. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/output/parser.py +0 -0
  67. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/output/spooler.py +0 -0
  68. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/output/truncation.py +0 -0
  69. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/providers/anthropic.py +0 -0
  70. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/providers/base.py +0 -0
  71. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/providers/grok.py +0 -0
  72. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/providers/mock.py +0 -0
  73. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/providers/ollama.py +0 -0
  74. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/providers/openai.py +0 -0
  75. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/providers/openrouter.py +0 -0
  76. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/routing/__init__.py +0 -0
  77. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/routing/anti_verbosity.py +0 -0
  78. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/routing/classifier.py +0 -0
  79. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/routing/router.py +0 -0
  80. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/sandbox/__init__.py +0 -0
  81. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/sandbox/base.py +0 -0
  82. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/sandbox/bwrap.py +0 -0
  83. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/sandbox/dispatcher.py +0 -0
  84. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/sandbox/process.py +0 -0
  85. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/sandbox/seatbelt.py +0 -0
  86. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/session/__init__.py +0 -0
  87. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/session/checkpoint.py +0 -0
  88. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/session/dag.py +0 -0
  89. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/session/events.py +0 -0
  90. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/session/logger.py +0 -0
  91. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/session/undo.py +0 -0
  92. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/tools/__init__.py +0 -0
  93. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/tools/base.py +0 -0
  94. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/tools/fs.py +0 -0
  95. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/tools/output_reader.py +0 -0
  96. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/tui/__init__.py +0 -0
  97. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/tui/commands.py +0 -0
  98. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/tui/completer.py +0 -0
  99. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/tui/diff_view.py +0 -0
  100. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/tui/keys.py +0 -0
  101. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/tui/status.py +0 -0
  102. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari/tui/style.py +0 -0
  103. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari_cli.egg-info/SOURCES.txt +0 -0
  104. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari_cli.egg-info/dependency_links.txt +0 -0
  105. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari_cli.egg-info/entry_points.txt +0 -0
  106. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari_cli.egg-info/requires.txt +0 -0
  107. {pari_cli-0.1.3 → pari_cli-0.1.4}/pari_cli.egg-info/top_level.txt +0 -0
  108. {pari_cli-0.1.3 → pari_cli-0.1.4}/setup.cfg +0 -0
@@ -0,0 +1,111 @@
1
+ Metadata-Version: 2.4
2
+ Name: pari-cli
3
+ Version: 0.1.4
4
+ Summary: A resilient, sandbox-first terminal coding agent engineered for high pass rates and low token burn.
5
+ Author: PARI Contributors
6
+ License: MIT
7
+ Project-URL: Homepage, https://pari-cli.in
8
+ Project-URL: Documentation, https://pari-cli.in
9
+ Project-URL: Repository, https://github.com/MdTanwer/pari
10
+ Keywords: ai,coding-agent,terminal,llm,developer-tools
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: POSIX :: Linux
16
+ Classifier: Operating System :: MacOS
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Software Development :: Code Generators
20
+ Requires-Python: >=3.11
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: rich>=13.0.0
24
+ Requires-Dist: prompt_toolkit>=3.0.36
25
+ Requires-Dist: pydantic>=2.0.0
26
+ Requires-Dist: httpx>=0.24.0
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest>=7.4.0; extra == "dev"
29
+ Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
30
+ Requires-Dist: ruff>=0.12.10; extra == "dev"
31
+ Requires-Dist: pre-commit>=4.3.0; extra == "dev"
32
+ Requires-Dist: pycodestyle>=2.12.0; extra == "dev"
33
+ Provides-Extra: ast
34
+ Requires-Dist: tree-sitter>=0.21.0; extra == "ast"
35
+ Requires-Dist: tree-sitter-languages>=1.10.0; extra == "ast"
36
+ Requires-Dist: networkx>=3.0; extra == "ast"
37
+ Requires-Dist: grep-ast>=0.3.0; extra == "ast"
38
+ Provides-Extra: cloud
39
+ Requires-Dist: e2b>=0.14.0; extra == "cloud"
40
+ Provides-Extra: all
41
+ Requires-Dist: pari-cli[ast,cloud,dev]; extra == "all"
42
+ Dynamic: license-file
43
+
44
+ # PARI — Next-Generation Terminal Coding Agent
45
+
46
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
47
+ [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
48
+ [![Architecture: 11-Layer Stack](https://img.shields.io/badge/architecture-11--layer-green.svg)](docs/architecture/overview.md)
49
+ [![Security: Kernel Sandboxed](https://img.shields.io/badge/sandbox-bwrap%20%7C%20Seatbelt%20%7C%20E2B-orange.svg)](docs/layers/layer-0-sandboxing.md)
50
+
51
+ ---
52
+
53
+ ## ⚡ The Core Thesis
54
+
55
+ $$\text{\bf Agent failure is a systems engineering failure, not a reasoning failure.}$$
56
+
57
+ The exact same frontier reasoning model (Claude 3.7 Sonnet, GPT-4o) demonstrates a **15% pass rate** in a naive loop, but achieves a **50–55% pass rate** inside an engineered harness. The 35% delta is not model intelligence; it is accounted for by **six fatal systems failure modes**:
58
+
59
+ 1. **Unbounded stdout poisoning** $\rightarrow$ Solved by Layer 7 (50KB/2k-line disk spooler + balanced head/tail extraction).
60
+ 2. **Mid-stream token truncation** $\rightarrow$ Solved by Layer 4 (Strict `stop_reason == "length"` rejection guard).
61
+ 3. **File write race conditions** $\rightarrow$ Solved by Layer 6 (`FileMutationQueue` canonical realpath serialization).
62
+ 4. **KV cache invalidation cascades** $\rightarrow$ Solved by Layer 2 (Immutable Baseline Epochs freezing Tiers 1–4).
63
+ 5. **Blind file traversal loops** $\rightarrow$ Solved by Layer 3 (v1: Fast substring ripgrep & globbing / v2: Tree-sitter AST fallback).
64
+ 6. **Unsandboxed host execution** $\rightarrow$ Solved by Layer 0 (Multi-platform kernel isolation: `bwrap`, `Seatbelt`, E2B MicroVMs).
65
+
66
+ ---
67
+
68
+ ## 🚀 Installation & Quick Start
69
+
70
+ Official Website & Documentation: **[https://pari-cli.in](https://pari-cli.in)**
71
+
72
+ Choose your platform's installation command:
73
+
74
+ ### 1. macOS & Linux (`curl`)
75
+ ```bash
76
+ curl -fsSL https://pari-cli.in/install.sh | bash
77
+ ```
78
+
79
+ ### 2. Windows (`irm` in PowerShell)
80
+ ```powershell
81
+ irm https://www.pari-cli.in/install.ps1 | iex
82
+ ```
83
+
84
+ ### 3. Via `pip` / `pipx` / `uv`
85
+ ```bash
86
+ # Recommended (isolated CLI)
87
+ pipx install pari-cli
88
+
89
+ # Fast via uv
90
+ uv tool install pari-cli
91
+
92
+ # Standard pip
93
+ pip install pari-cli
94
+ ```
95
+
96
+ ---
97
+
98
+ ### 💻 Launch Interactive Session
99
+ ```bash
100
+ # Export your API key
101
+ export ANTHROPIC_API_KEY="sk-ant-..."
102
+
103
+ # Launch PARI anywhere in your project workspace
104
+ pari-cli
105
+ ```
106
+
107
+ ---
108
+
109
+ ## ⚖️ License
110
+
111
+ MIT License. See [LICENSE](LICENSE) for details.
@@ -0,0 +1,68 @@
1
+ # PARI — Next-Generation Terminal Coding Agent
2
+
3
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
4
+ [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
5
+ [![Architecture: 11-Layer Stack](https://img.shields.io/badge/architecture-11--layer-green.svg)](docs/architecture/overview.md)
6
+ [![Security: Kernel Sandboxed](https://img.shields.io/badge/sandbox-bwrap%20%7C%20Seatbelt%20%7C%20E2B-orange.svg)](docs/layers/layer-0-sandboxing.md)
7
+
8
+ ---
9
+
10
+ ## ⚡ The Core Thesis
11
+
12
+ $$\text{\bf Agent failure is a systems engineering failure, not a reasoning failure.}$$
13
+
14
+ The exact same frontier reasoning model (Claude 3.7 Sonnet, GPT-4o) demonstrates a **15% pass rate** in a naive loop, but achieves a **50–55% pass rate** inside an engineered harness. The 35% delta is not model intelligence; it is accounted for by **six fatal systems failure modes**:
15
+
16
+ 1. **Unbounded stdout poisoning** $\rightarrow$ Solved by Layer 7 (50KB/2k-line disk spooler + balanced head/tail extraction).
17
+ 2. **Mid-stream token truncation** $\rightarrow$ Solved by Layer 4 (Strict `stop_reason == "length"` rejection guard).
18
+ 3. **File write race conditions** $\rightarrow$ Solved by Layer 6 (`FileMutationQueue` canonical realpath serialization).
19
+ 4. **KV cache invalidation cascades** $\rightarrow$ Solved by Layer 2 (Immutable Baseline Epochs freezing Tiers 1–4).
20
+ 5. **Blind file traversal loops** $\rightarrow$ Solved by Layer 3 (v1: Fast substring ripgrep & globbing / v2: Tree-sitter AST fallback).
21
+ 6. **Unsandboxed host execution** $\rightarrow$ Solved by Layer 0 (Multi-platform kernel isolation: `bwrap`, `Seatbelt`, E2B MicroVMs).
22
+
23
+ ---
24
+
25
+ ## 🚀 Installation & Quick Start
26
+
27
+ Official Website & Documentation: **[https://pari-cli.in](https://pari-cli.in)**
28
+
29
+ Choose your platform's installation command:
30
+
31
+ ### 1. macOS & Linux (`curl`)
32
+ ```bash
33
+ curl -fsSL https://pari-cli.in/install.sh | bash
34
+ ```
35
+
36
+ ### 2. Windows (`irm` in PowerShell)
37
+ ```powershell
38
+ irm https://www.pari-cli.in/install.ps1 | iex
39
+ ```
40
+
41
+ ### 3. Via `pip` / `pipx` / `uv`
42
+ ```bash
43
+ # Recommended (isolated CLI)
44
+ pipx install pari-cli
45
+
46
+ # Fast via uv
47
+ uv tool install pari-cli
48
+
49
+ # Standard pip
50
+ pip install pari-cli
51
+ ```
52
+
53
+ ---
54
+
55
+ ### 💻 Launch Interactive Session
56
+ ```bash
57
+ # Export your API key
58
+ export ANTHROPIC_API_KEY="sk-ant-..."
59
+
60
+ # Launch PARI anywhere in your project workspace
61
+ pari-cli
62
+ ```
63
+
64
+ ---
65
+
66
+ ## ⚖️ License
67
+
68
+ MIT License. See [LICENSE](LICENSE) for details.
@@ -1,3 +1,3 @@
1
1
  """PARI — High-performance terminal coding agent."""
2
2
 
3
- __version__ = "0.1.3"
3
+ __version__ = "0.1.4"
@@ -5,7 +5,9 @@ The loop itself decides when to stop. These values are the limits it checks.
5
5
 
6
6
  MAX_TURNS = 40
7
7
  TURN_WARNING = 30
8
+ TURN_EXTENSION = 10
8
9
  DAEMON_IDLE_SECONDS = 10
10
+ TURN_LIMIT_PROMPT = "[Turn limit reached. Continue (+10 turns) | Revert | Stop]?"
9
11
 
10
12
  # Shown when stop_reason is "length". The loop must drop the pending tool
11
13
  # calls before it appends this notice. A truncated edit must not touch disk.
@@ -17,6 +17,7 @@ from pari.agent.guards import (
17
17
  DAEMON_IDLE_SECONDS,
18
18
  LENGTH_TRUNCATION_NOTICE,
19
19
  MAX_TURNS,
20
+ TURN_LIMIT_PROMPT,
20
21
  TURN_WARNING,
21
22
  format_daemon_notice,
22
23
  is_length_stop,
@@ -49,7 +50,6 @@ class TurnResult:
49
50
  cached_tokens: int = 0
50
51
 
51
52
 
52
- _TURN_LIMIT = "[Turn limit reached. Continue (+10 turns) | Revert | Stop]?"
53
53
  _FILE_TOOLS = frozenset({"write_file", "edit_file"})
54
54
 
55
55
 
@@ -66,6 +66,7 @@ class AgentExecutionLoop:
66
66
  undo: GuardedUndoManager | None = None,
67
67
  workspace: Path | None = None,
68
68
  on_tool_call: Callable[[str, dict[str, Any]], None] | None = None,
69
+ on_tool_result: Callable[[ToolCall, str], None] | None = None,
69
70
  on_diff: Callable[[str, str], None] | None = None,
70
71
  on_warning: Callable[[str], None] | None = None,
71
72
  ):
@@ -76,6 +77,7 @@ class AgentExecutionLoop:
76
77
  self.undo = undo
77
78
  self.workspace = workspace
78
79
  self.on_tool_call = on_tool_call
80
+ self.on_tool_result = on_tool_result
79
81
  self.on_diff = on_diff
80
82
  self.on_warning = on_warning
81
83
  self._warning = ""
@@ -140,19 +142,21 @@ class AgentExecutionLoop:
140
142
  return finish(halt, executed, True)
141
143
  history = _append(history, response_text)
142
144
  pending = feedback
143
- return finish(_TURN_LIMIT, executed, True, capture_diff=True)
145
+ return finish(TURN_LIMIT_PROMPT, executed, True, capture_diff=True)
144
146
 
145
147
  async def _run_calls(
146
148
  self, calls: list[ToolCall], executed: int
147
149
  ) -> tuple[str, int, str | None]:
148
150
  """Run the calls in order. A failure stops the rest of this batch."""
149
151
  notes: list[str] = []
150
- for call in calls:
152
+ for index, call in enumerate(calls):
151
153
  path = _file_path(call)
152
154
  before = self._read_rel(path) if path is not None else ""
153
155
  try:
154
156
  text = await self._execute_call(call)
155
157
  except Exception as exc:
158
+ self._report_result(call, f"{type(exc).__name__}: {exc}")
159
+ self._skip_rest(calls[index + 1 :])
156
160
  return self._fail(type(exc).__name__, str(exc), executed)
157
161
  executed += 1
158
162
  if path is not None:
@@ -160,7 +164,10 @@ class AgentExecutionLoop:
160
164
  self._snapshot(path, before, after)
161
165
  lint = lint_python(path, after)
162
166
  if lint is not None:
167
+ self._report_result(call, lint)
168
+ self._skip_rest(calls[index + 1 :])
163
169
  return self._fail("LINT ERROR", lint, executed)
170
+ self._report_result(call, text)
164
171
  notes.append(text)
165
172
  return "\n".join(notes), executed, None
166
173
 
@@ -179,6 +186,14 @@ class AgentExecutionLoop:
179
186
  self.logger.append_event("TOOL_RESULT", {"tool": call.name, "output": text})
180
187
  return text
181
188
 
189
+ def _report_result(self, call: ToolCall, text: str) -> None:
190
+ if self.on_tool_result is not None:
191
+ self.on_tool_result(call, text)
192
+
193
+ def _skip_rest(self, calls: list[ToolCall]) -> None:
194
+ for call in calls:
195
+ self._report_result(call, "Skipped because an earlier call failed.")
196
+
182
197
  def _log_assistant(self, text: str) -> None:
183
198
  if self.logger is None:
184
199
  return
@@ -14,12 +14,17 @@ from collections.abc import Callable
14
14
  from pathlib import Path
15
15
  from typing import Any
16
16
 
17
- from pari.agent.guards import is_length_stop
18
- from pari.agent.loop import AgentExecutionLoop
17
+ from pari.agent.guards import (
18
+ MAX_TURNS,
19
+ TURN_EXTENSION,
20
+ TURN_LIMIT_PROMPT,
21
+ is_length_stop,
22
+ )
23
+ from pari.agent.loop import AgentExecutionLoop, TurnResult
19
24
  from pari.agent.reflection import ReflectionManager
20
25
  from pari.intelligence.repomap import RepoMapGenerator
21
26
  from pari.providers import get_provider
22
- from pari.providers.base import LLMProvider, ModelResponse, ProviderMessage
27
+ from pari.providers.base import LLMProvider, ModelResponse, ProviderMessage, ToolCall
23
28
  from pari.routing.router import DualModelRouter, router_for
24
29
  from pari.session.events import SessionEventType
25
30
  from pari.session.logger import SessionLogger
@@ -39,6 +44,7 @@ class AgentRuntime:
39
44
  edit_fallback: str = "none",
40
45
  repomap: str = "off",
41
46
  router: DualModelRouter | None = None,
47
+ sandbox_mode: str | None = None,
42
48
  ):
43
49
  self.workspace = workspace.resolve()
44
50
  self.session_file = session_file or (self.workspace / ".pari" / "session.jsonl")
@@ -51,7 +57,14 @@ class AgentRuntime:
51
57
  self._owns_router = router is None
52
58
  self.router = router if router is not None else router_for(self.provider)
53
59
  self.undo_manager = GuardedUndoManager(self.workspace)
54
- self.tools = get_default_registry(self.workspace, edit_fallback=edit_fallback)
60
+ self.tools = get_default_registry(
61
+ self.workspace,
62
+ edit_fallback=edit_fallback,
63
+ sandbox_mode=sandbox_mode,
64
+ )
65
+ self.awaiting_limit = False
66
+ self.last_result: TurnResult | None = None
67
+ self._tool_followup = False
55
68
  self.pinned_files: list[str] = []
56
69
  self.session_id: str | None = None
57
70
  self.messages: list[ProviderMessage] = []
@@ -110,23 +123,33 @@ class AgentRuntime:
110
123
  def add_pinned_file(self, rel_path: str) -> bool:
111
124
  """Pin reference file into active context."""
112
125
  file_path = (self.workspace / rel_path).resolve()
113
- if not file_path.exists():
126
+ if not file_path.is_file():
127
+ return False
128
+ try:
129
+ clean_rel = str(file_path.relative_to(self.workspace))
130
+ except ValueError:
114
131
  return False
115
- clean_rel = str(file_path.relative_to(self.workspace))
116
132
  if clean_rel not in self.pinned_files:
117
133
  self.pinned_files.append(clean_rel)
118
134
  return True
119
135
 
136
+ async def continue_turn(self) -> str:
137
+ """Run another block of turns after the user chooses Continue."""
138
+ return await self.execute_turn("Continue.", max_turns=TURN_EXTENSION)
139
+
120
140
  async def execute_turn(
121
141
  self,
122
142
  prompt: str,
123
143
  on_tool_call: Callable[[str, dict[str, Any]], None] | None = None,
124
144
  on_diff: Callable[[str, str], None] | None = None,
145
+ *,
146
+ max_turns: int | None = None,
125
147
  ) -> str:
126
148
  """Execute conversational turn with autonomous tool execution."""
127
149
  self.on_tool_call = on_tool_call
128
150
  self.on_diff = on_diff
129
151
  self.turn_warning = ""
152
+ self._tool_followup = False
130
153
  if not self.session_id:
131
154
  self.start_session()
132
155
  self.router.begin_turn()
@@ -134,7 +157,11 @@ class AgentRuntime:
134
157
  schemas = self.tools.get_schemas()
135
158
 
136
159
  async def step(context: str, _turn: int) -> ModelResponse:
137
- self.messages.append(ProviderMessage(role="user", content=context))
160
+ # After a tool call, the results are already on the transcript.
161
+ # A new user message here would hide them from the model.
162
+ if not self._tool_followup:
163
+ self.messages.append(ProviderMessage(role="user", content=context))
164
+ self._tool_followup = False
138
165
  # Wide prompts plan once, with no tools. The editor runs the calls.
139
166
  resp = await self.router.complete(
140
167
  self.messages,
@@ -143,7 +170,11 @@ class AgentRuntime:
143
170
  active_files=self.pinned_files,
144
171
  )
145
172
  # Drop tool calls that were cut off so the next step cannot rerun them.
146
- calls = [] if is_length_stop(resp.stop_reason) else resp.tool_calls
173
+ calls = [] if is_length_stop(resp.stop_reason) else list(resp.tool_calls)
174
+ for index, call in enumerate(calls):
175
+ if not call.id:
176
+ call.id = f"call_{index}"
177
+ self._tool_followup = bool(calls)
147
178
  self.messages.append(
148
179
  ProviderMessage(
149
180
  role="assistant",
@@ -153,18 +184,25 @@ class AgentRuntime:
153
184
  )
154
185
  return resp
155
186
 
187
+ def remember_result(call: ToolCall, text: str) -> None:
188
+ self.messages.append(
189
+ ProviderMessage(role="tool", content=text, tool_call_id=call.id)
190
+ )
191
+
156
192
  dag = self.workspace / ".pari" / "sessions" / f"{self.session_id}.jsonl"
157
193
 
158
194
  def remember_warning(text: str) -> None:
159
195
  self.turn_warning = text
160
196
 
161
197
  loop = AgentExecutionLoop(
198
+ max_turns if max_turns is not None else MAX_TURNS,
162
199
  registry=self.tools,
163
200
  reflection=ReflectionManager(),
164
201
  logger=SessionLogger(dag),
165
202
  undo=self.undo_manager,
166
203
  workspace=self.workspace,
167
204
  on_tool_call=on_tool_call,
205
+ on_tool_result=remember_result,
168
206
  on_diff=on_diff,
169
207
  on_warning=remember_warning,
170
208
  )
@@ -189,6 +227,8 @@ class AgentRuntime:
189
227
  self.tokens_in = result.tokens_in
190
228
  self.tokens_out = result.tokens_out
191
229
  self.cached_tokens = result.cached_tokens
230
+ self.last_result = result
231
+ self.awaiting_limit = result.response_text == TURN_LIMIT_PROMPT
192
232
  return result.response_text
193
233
 
194
234
  def _repo_map(self) -> str:
@@ -1,8 +1,9 @@
1
- """Account sign-in for the five providers.
1
+ """Account sign-in for providers that have a browser or device login.
2
2
 
3
3
  OpenAI, Anthropic, and OpenRouter open a browser and accept the redirect URL
4
4
  if the local callback does not finish. Grok uses a device code. DeepSeek has
5
- no browser login, so the account token is pasted. Radius is not a provider.
5
+ no account login, so that path does not ask for a token. Radius is not a
6
+ provider.
6
7
  """
7
8
 
8
9
  import base64
@@ -89,10 +90,10 @@ def sign_in_account(provider_id: str, ui: AccountUI, store: AuthStore) -> str:
89
90
  """Run that provider's account login and store the credential."""
90
91
  info = get_info(provider_id)
91
92
  if info.id == "deepseek":
92
- token = ui.read_line("Paste your DeepSeek account token:")
93
- if not token or not token.strip():
94
- raise LoginCancelled()
95
- return sign_in(info.id, token, "account", store)
93
+ raise ValueError(
94
+ "DeepSeek has no account sign-in. "
95
+ "Use pari-cli login deepseek --api-key <key>."
96
+ )
96
97
  if info.id == "openai":
97
98
  cred = _login_openai(ui, store)
98
99
  elif info.id == "anthropic":
@@ -1,15 +1,26 @@
1
1
  """``pari-cli login`` screens: method, provider, then the sign-in itself."""
2
2
 
3
3
  import base64
4
+ import ctypes
4
5
  import getpass
5
6
  import os
6
7
  import select
7
8
  import sys
8
- import termios
9
9
  import time
10
- import tty
11
10
  import webbrowser
12
11
 
12
+
13
+ try:
14
+ import msvcrt
15
+ except ImportError:
16
+ msvcrt = None
17
+ try:
18
+ import termios
19
+ import tty
20
+ except ImportError:
21
+ termios = None
22
+ tty = None
23
+
13
24
  from pari.auth.account import AccountUI, LoginCancelled, sign_in_account
14
25
  from pari.auth.login import sign_in, sign_out, status_text
15
26
  from pari.auth.menu import (
@@ -128,6 +139,12 @@ def _finish(kind: str, provider_id: str, ui: AccountUI, store: AuthStore) -> str
128
139
  try:
129
140
  if kind == "api_key":
130
141
  return _read_api_key(ui, info, store)
142
+ if info.id == "deepseek":
143
+ ui.show_error(
144
+ "DeepSeek has no account sign-in. "
145
+ "Use pari-cli login deepseek --api-key <key>."
146
+ )
147
+ return None
131
148
  return sign_in_account(info.id, ui, store)
132
149
  except LoginCancelled:
133
150
  return None
@@ -279,6 +296,10 @@ class TerminalLogin:
279
296
  if self._raw:
280
297
  return
281
298
  self._fd = sys.stdin.fileno()
299
+ if termios is None or tty is None:
300
+ _enable_windows_vt()
301
+ self._raw = True
302
+ return
282
303
  current = termios.tcgetattr(self._fd)
283
304
  self._term = _copy_mode(current)
284
305
  # cbreak, not raw: raw turns off newline output, so each row is drawn
@@ -393,7 +414,17 @@ def read_menu_key(fd: int, pending: bytes, timeout: float | None) -> tuple[str,
393
414
  return key, pending
394
415
 
395
416
 
417
+ _WIN_ARROWS = {
418
+ "H": b"\x1b[A",
419
+ "P": b"\x1b[B",
420
+ "K": b"\x1b[D",
421
+ "M": b"\x1b[C",
422
+ }
423
+
424
+
396
425
  def _read_chunk(fd: int, timeout: float | None) -> bytes:
426
+ if msvcrt is not None:
427
+ return _read_windows(timeout)
397
428
  if timeout is not None:
398
429
  ready, _, _ = select.select([fd], [], [], timeout)
399
430
  if not ready:
@@ -404,6 +435,35 @@ def _read_chunk(fd: int, timeout: float | None) -> bytes:
404
435
  return b""
405
436
 
406
437
 
438
+ def _read_windows(timeout: float | None) -> bytes:
439
+ if timeout is not None and not _windows_ready(timeout):
440
+ return b""
441
+ char = msvcrt.getwch()
442
+ if char in ("\x00", "\xe0"):
443
+ return _WIN_ARROWS.get(msvcrt.getwch(), b"")
444
+ return char.encode("utf-8")
445
+
446
+
447
+ def _windows_ready(timeout: float) -> bool:
448
+ deadline = time.monotonic() + timeout
449
+ while not msvcrt.kbhit():
450
+ if time.monotonic() >= deadline:
451
+ return False
452
+ time.sleep(0.01)
453
+ return True
454
+
455
+
456
+ def _enable_windows_vt() -> None:
457
+ if msvcrt is None:
458
+ return
459
+ kernel = ctypes.windll.kernel32
460
+ handle = kernel.GetStdHandle(-11)
461
+ mode = ctypes.c_uint()
462
+ if not kernel.GetConsoleMode(handle, ctypes.byref(mode)):
463
+ return
464
+ kernel.SetConsoleMode(handle, mode.value | 0x0004)
465
+
466
+
407
467
  def _split_sequence(buf: bytes) -> tuple[bytes | None, bytes]:
408
468
  """Split one key from ``buf``. None means an unfinished Escape sequence."""
409
469
  if not buf:
@@ -35,6 +35,11 @@ def sign_in(
35
35
  if method not in ("api_key", "account"):
36
36
  raise ValueError("Sign in with an API key or an account token.")
37
37
  info = get_info(provider_id)
38
+ if method == "account" and info.id == "deepseek":
39
+ raise ValueError(
40
+ "DeepSeek has no account sign-in. "
41
+ "Use pari-cli login deepseek --api-key <key>."
42
+ )
38
43
  token = secret.strip()
39
44
  if not token:
40
45
  raise ValueError("The credential is empty.")
@@ -50,6 +55,8 @@ def sign_in(
50
55
  expires = (extra or {}).get("expires")
51
56
  if isinstance(expires, int):
52
57
  row["expires"] = expires
58
+ # Latest sign-in is the session default, so it has to be last.
59
+ data.pop(info.id, None)
53
60
  data[info.id] = row
54
61
  auth.save(data)
55
62
  label = "API key" if method == "api_key" else "account"
@@ -142,7 +149,49 @@ def _now_ms() -> int:
142
149
  return int(time.time() * 1000)
143
150
 
144
151
 
152
+ NO_MODEL = (
153
+ "No model selected. Sign in with `pari-cli login`, "
154
+ "or pass --model, or set PARI_MODEL."
155
+ )
156
+
157
+
158
+ def default_model_name(store: AuthStore | None = None) -> str | None:
159
+ """Model used when the CLI was started without ``--model``.
160
+
161
+ ``PARI_MODEL`` wins. Otherwise the latest saved sign-in is used, then an
162
+ API key already in the environment. Nothing is chosen when no credential
163
+ is available.
164
+ """
165
+ chosen = os.environ.get("PARI_MODEL", "").strip()
166
+ if chosen:
167
+ return chosen
168
+ saved = _saved_provider_ids((store or AuthStore()).load())
169
+ if saved:
170
+ return get_info(saved[-1]).default_model
171
+ for info in PROVIDERS:
172
+ if os.environ.get(info.env, "").strip():
173
+ return info.default_model
174
+ return None
175
+
176
+
177
+ def _saved_provider_ids(data: dict) -> list[str]:
178
+ ids: list[str] = []
179
+ for key, row in data.items():
180
+ if not isinstance(key, str) or not isinstance(row, dict):
181
+ continue
182
+ secret = row.get("secret")
183
+ if not isinstance(secret, str) or not secret.strip():
184
+ continue
185
+ try:
186
+ ids.append(get_info(key).id)
187
+ except ValueError:
188
+ continue
189
+ return ids
190
+
191
+
145
192
  def sign_in_hint(info: ProviderInfo) -> str:
193
+ if info.id == "deepseek":
194
+ return "Sign in to DeepSeek: pari-cli login deepseek --api-key <key>."
146
195
  return (
147
196
  f"Sign in to {info.label}: "
148
197
  f"pari-cli login {info.id} --api-key <key> "
@@ -30,6 +30,8 @@ def provider_rows(method: str, store: AuthStore | None = None) -> list[ProviderC
30
30
  """Providers in name order, each with a status that does not include the secret."""
31
31
  data = (store or AuthStore()).load()
32
32
  infos = sorted(PROVIDERS, key=lambda item: item.label.lower())
33
+ if method == "account":
34
+ infos = [info for info in infos if info.id != "deepseek"]
33
35
  return [
34
36
  ProviderChoice(info.id, info.label, _status(info, method, data))
35
37
  for info in infos