kiwimatecoder 0.1.0__py3-none-any.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 (104) hide show
  1. kiwimatecoder/__init__.py +8 -0
  2. kiwimatecoder/__main__.py +12 -0
  3. kiwimatecoder/acp/__init__.py +13 -0
  4. kiwimatecoder/acp/protocol.py +115 -0
  5. kiwimatecoder/acp/server.py +823 -0
  6. kiwimatecoder/acp/stdio.py +32 -0
  7. kiwimatecoder/agent.py +1162 -0
  8. kiwimatecoder/ai.py +68 -0
  9. kiwimatecoder/audit.py +65 -0
  10. kiwimatecoder/browser.py +261 -0
  11. kiwimatecoder/catalog.py +445 -0
  12. kiwimatecoder/checkpoints.py +103 -0
  13. kiwimatecoder/client.py +820 -0
  14. kiwimatecoder/clipboard.py +99 -0
  15. kiwimatecoder/commands.py +6757 -0
  16. kiwimatecoder/config.py +5335 -0
  17. kiwimatecoder/diagnostics.py +295 -0
  18. kiwimatecoder/documents.py +299 -0
  19. kiwimatecoder/evals/__init__.py +39 -0
  20. kiwimatecoder/evals/cases.py +230 -0
  21. kiwimatecoder/evals/render.py +46 -0
  22. kiwimatecoder/evals/runner.py +319 -0
  23. kiwimatecoder/events.py +95 -0
  24. kiwimatecoder/forge.py +172 -0
  25. kiwimatecoder/git.py +164 -0
  26. kiwimatecoder/headless.py +255 -0
  27. kiwimatecoder/hooks.py +170 -0
  28. kiwimatecoder/hunks.py +260 -0
  29. kiwimatecoder/i18n.py +368 -0
  30. kiwimatecoder/images.py +195 -0
  31. kiwimatecoder/index/__init__.py +14 -0
  32. kiwimatecoder/index/builder.py +376 -0
  33. kiwimatecoder/index/embeddings.py +151 -0
  34. kiwimatecoder/index/search.py +247 -0
  35. kiwimatecoder/index/store.py +251 -0
  36. kiwimatecoder/instructions.py +83 -0
  37. kiwimatecoder/jobs.py +594 -0
  38. kiwimatecoder/lsp/__init__.py +56 -0
  39. kiwimatecoder/lsp/client.py +548 -0
  40. kiwimatecoder/lsp/manager.py +353 -0
  41. kiwimatecoder/lsp/protocol.py +79 -0
  42. kiwimatecoder/main.py +4258 -0
  43. kiwimatecoder/mcp/__init__.py +45 -0
  44. kiwimatecoder/mcp/client.py +385 -0
  45. kiwimatecoder/mcp/errors.py +7 -0
  46. kiwimatecoder/mcp/manager.py +253 -0
  47. kiwimatecoder/mcp/protocol.py +132 -0
  48. kiwimatecoder/media.py +904 -0
  49. kiwimatecoder/memory.py +141 -0
  50. kiwimatecoder/network.py +112 -0
  51. kiwimatecoder/notify.py +139 -0
  52. kiwimatecoder/permissions.py +171 -0
  53. kiwimatecoder/pickers.py +417 -0
  54. kiwimatecoder/plugins.py +245 -0
  55. kiwimatecoder/pricing.py +167 -0
  56. kiwimatecoder/prompts.py +213 -0
  57. kiwimatecoder/providers.py +342 -0
  58. kiwimatecoder/redaction.py +66 -0
  59. kiwimatecoder/remote.py +294 -0
  60. kiwimatecoder/repl.py +1388 -0
  61. kiwimatecoder/routing.py +65 -0
  62. kiwimatecoder/sandbox.py +169 -0
  63. kiwimatecoder/sdk.py +146 -0
  64. kiwimatecoder/session.py +574 -0
  65. kiwimatecoder/share.py +386 -0
  66. kiwimatecoder/shell.py +660 -0
  67. kiwimatecoder/skills.py +138 -0
  68. kiwimatecoder/sync.py +513 -0
  69. kiwimatecoder/team.py +249 -0
  70. kiwimatecoder/telemetry.py +392 -0
  71. kiwimatecoder/templates.py +108 -0
  72. kiwimatecoder/tools/__init__.py +190 -0
  73. kiwimatecoder/tools/ask.py +62 -0
  74. kiwimatecoder/tools/base.py +69 -0
  75. kiwimatecoder/tools/browser.py +261 -0
  76. kiwimatecoder/tools/edit_file.py +143 -0
  77. kiwimatecoder/tools/forge.py +207 -0
  78. kiwimatecoder/tools/git.py +158 -0
  79. kiwimatecoder/tools/image.py +300 -0
  80. kiwimatecoder/tools/io.py +346 -0
  81. kiwimatecoder/tools/list_dir.py +67 -0
  82. kiwimatecoder/tools/lsp.py +254 -0
  83. kiwimatecoder/tools/memory.py +115 -0
  84. kiwimatecoder/tools/paths.py +234 -0
  85. kiwimatecoder/tools/read_file.py +113 -0
  86. kiwimatecoder/tools/registry.py +91 -0
  87. kiwimatecoder/tools/run_bash.py +189 -0
  88. kiwimatecoder/tools/search.py +224 -0
  89. kiwimatecoder/tools/selection.py +108 -0
  90. kiwimatecoder/tools/shell.py +158 -0
  91. kiwimatecoder/tools/skill.py +46 -0
  92. kiwimatecoder/tools/task.py +61 -0
  93. kiwimatecoder/tools/todo.py +77 -0
  94. kiwimatecoder/tools/web.py +146 -0
  95. kiwimatecoder/tools/write_file.py +83 -0
  96. kiwimatecoder/ui.py +189 -0
  97. kiwimatecoder/updater.py +502 -0
  98. kiwimatecoder/web.py +499 -0
  99. kiwimatecoder-0.1.0.dist-info/METADATA +2016 -0
  100. kiwimatecoder-0.1.0.dist-info/RECORD +104 -0
  101. kiwimatecoder-0.1.0.dist-info/WHEEL +5 -0
  102. kiwimatecoder-0.1.0.dist-info/entry_points.txt +2 -0
  103. kiwimatecoder-0.1.0.dist-info/licenses/LICENSE +21 -0
  104. kiwimatecoder-0.1.0.dist-info/top_level.txt +1 -0
kiwimatecoder/agent.py ADDED
@@ -0,0 +1,1162 @@
1
+ """The agentic tool-calling loop."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import time
9
+ from contextlib import nullcontext
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Any, Callable
13
+
14
+ from rich.console import Console
15
+ from rich.markup import escape
16
+ from rich.text import Text
17
+
18
+ from kiwimatecoder import audit, events, hooks, images, tools, ui
19
+ from kiwimatecoder.client import (
20
+ AssembledToolCall,
21
+ Done,
22
+ ProviderError,
23
+ TextDelta,
24
+ ToolCallAssembler,
25
+ ToolCallDelta,
26
+ UnifiedClient,
27
+ Usage,
28
+ )
29
+ from kiwimatecoder.i18n import t
30
+ from kiwimatecoder.permissions import ConfirmFn, PermissionMode, gate
31
+ from kiwimatecoder.prompts import build_system_prompt
32
+ from kiwimatecoder.redaction import redact
33
+ from kiwimatecoder.session import Session
34
+ from kiwimatecoder.tools.base import FunctionTool, ToolResult
35
+
36
+ POST_EDIT_DIAGNOSTICS_TIMEOUT = 3.0
37
+ MAX_EDIT_DIAGNOSTICS = 10
38
+ TASK_RESULT_MAX_CHARS = 8000
39
+
40
+ EventCallback = Callable[[str, dict[str, Any]], None]
41
+
42
+ _log = logging.getLogger(__name__)
43
+
44
+
45
+ class TaskConsole(Console):
46
+ """Console wrapper that tags every child line with dim ``[task]``.
47
+
48
+ Prints are delegated to the parent console so subagent output stays in the
49
+ same stream (and under the same test harness); the tag is emitted once per
50
+ line even when text is streamed with ``end=""``.
51
+ """
52
+
53
+ def __init__(self, parent: Console, label: str = "task") -> None:
54
+ super().__init__(quiet=True)
55
+ self._parent = parent
56
+ self._tag = f"[dim]\\[{label}][/dim]"
57
+ self._at_line_start = True
58
+
59
+ def print(self, *objects: Any, **kwargs: Any) -> None:
60
+ end = str(kwargs.get("end", "\n"))
61
+ if objects and self._at_line_start:
62
+ self._parent.print(self._tag, end=" ", markup=True, highlight=False)
63
+ self._at_line_start = False
64
+ self._parent.print(*objects, **kwargs)
65
+ if end.endswith("\n"):
66
+ self._at_line_start = True
67
+
68
+ def status(self, status: Any, **kwargs: Any) -> Any:
69
+ return self._parent.status(f"{self._tag} {status}", **kwargs)
70
+
71
+
72
+ class _NoopStatus:
73
+ """Status stand-in that does nothing when spinners are disabled."""
74
+
75
+ def start(self) -> None:
76
+ pass
77
+
78
+ def stop(self) -> None:
79
+ pass
80
+
81
+
82
+ @dataclass
83
+ class _PreparedCall:
84
+ """A tool call that passed parsing, approval, hooks, and checkpointing."""
85
+
86
+ call: AssembledToolCall
87
+ tool: FunctionTool
88
+ args: dict[str, Any]
89
+ original_args: dict[str, Any]
90
+ summary: str
91
+ partial_hunks: tuple[int, ...] | None = None
92
+
93
+
94
+ class Agent:
95
+ """Drives one conversational turn, including any tool calls it triggers."""
96
+
97
+ session: Session
98
+ console: Console
99
+ confirm: ConfirmFn
100
+
101
+ def __init__(
102
+ self,
103
+ session: Session,
104
+ console: Console,
105
+ confirm: ConfirmFn,
106
+ bus: events.EventBus | None = None,
107
+ ascii_mode: bool = False,
108
+ output_mode: str = "normal",
109
+ event_handler: EventCallback | None = None,
110
+ max_turns: int | None = None,
111
+ render_text: bool = True,
112
+ spinner: str = "auto",
113
+ ) -> None:
114
+ self.session = session
115
+ self.console = console
116
+ self.confirm = confirm
117
+ self.bus = bus if bus is not None else events.BUS
118
+ self._budget_warned = False
119
+ self.ascii_mode = bool(ascii_mode)
120
+ self.output_mode = output_mode if output_mode in ui.OUTPUT_MODES else "normal"
121
+ self.event_handler = event_handler
122
+ self.max_turns = max_turns
123
+ self.render_text = render_text
124
+ self.spinner = spinner if spinner in ui.SPINNER_MODES else "auto"
125
+ self.spinner_enabled = self.spinner != "off"
126
+
127
+ def _emit(self, name: str, **payload: Any) -> None:
128
+ """Forward one render event, never letting a bad subscriber break a run."""
129
+ if self.event_handler is None:
130
+ return
131
+ try:
132
+ self.event_handler(name, payload)
133
+ except Exception:
134
+ _log.debug("event handler failed for %s", name, exc_info=True)
135
+
136
+ def _redacted_args(self, args: dict[str, Any]) -> dict[str, Any]:
137
+ """Return a JSON-safe copy of tool arguments with secrets redacted."""
138
+ try:
139
+ rendered = redact(
140
+ json.dumps(args, ensure_ascii=False, sort_keys=True, default=str)
141
+ )
142
+ parsed = json.loads(rendered)
143
+ return parsed if isinstance(parsed, dict) else {"value": parsed}
144
+ except (TypeError, ValueError):
145
+ return {"value": redact(str(args))}
146
+
147
+ def _glyph(self, name: str) -> str:
148
+ """Active glyph escaped for direct use inside Rich markup."""
149
+ return escape(ui.glyph(name, {"ui": {"ascii": self.ascii_mode}}))
150
+
151
+ def _print_markup(self, markup: str) -> None:
152
+ """Print styled markup as ``Text`` so logs keep the plain glyph text."""
153
+ self.console.print(Text.from_markup(markup))
154
+
155
+ def _client(self, provider_id: str | None = None) -> UnifiedClient:
156
+ from kiwimatecoder.config import (
157
+ get_key,
158
+ get_prompt_cache,
159
+ get_provider_config,
160
+ get_sampling,
161
+ )
162
+
163
+ provider = get_provider_config(provider_id) if provider_id else self.session.provider
164
+ key = get_key(provider.id)
165
+ if not key and provider.needs_key:
166
+ raise ProviderError(
167
+ t(
168
+ "error.no_key",
169
+ provider=provider.name,
170
+ provider_id=provider.id,
171
+ env=provider.key_env,
172
+ )
173
+ )
174
+ return UnifiedClient(
175
+ provider,
176
+ key or "",
177
+ sampling=get_sampling(),
178
+ prompt_cache=get_prompt_cache(),
179
+ )
180
+
181
+ def _request_messages(self) -> list[dict[str, Any]]:
182
+ self.session.trim_history(max_tokens=self.session.compact_at_tokens)
183
+ return [build_system_prompt(self.session)] + self.session.messages
184
+
185
+ def _drain_steering(self) -> bool:
186
+ """Inject every queued steering message as a user message.
187
+
188
+ Returns True when at least one message was queued and appended.
189
+ """
190
+ appended = False
191
+ while self.session.steering:
192
+ message = self.session.steering.popleft()
193
+ self.session.messages.append({"role": "user", "content": message})
194
+ appended = True
195
+ return appended
196
+
197
+ def _flush_pending_images(self) -> None:
198
+ """Attach queued images as one user message and clear the queue.
199
+
200
+ Called after the user message (for ``@path`` attachments) and after a
201
+ tool batch that attached images, so the message always follows the
202
+ content it belongs to and tool results keep their ordering.
203
+ """
204
+ if not self.session.pending_images:
205
+ return
206
+ pending = list(self.session.pending_images)
207
+ self.session.pending_images = []
208
+ self.session.messages.append(
209
+ images.image_message(pending, note="Images attached for analysis:")
210
+ )
211
+
212
+ async def run_turn(self, user_input: str) -> None:
213
+ """Process one user message, looping over tool calls until the model stops."""
214
+ blocked, reason = self._budget_exceeded()
215
+ if blocked:
216
+ self.console.print(f"[red]{escape(reason)}[/red]")
217
+ self._emit("done", reason="budget", error=reason)
218
+ return
219
+
220
+ from kiwimatecoder.config import get_model_routing
221
+ from kiwimatecoder.routing import choose_turn_model
222
+
223
+ routed_model = choose_turn_model(
224
+ user_input, self.session, get_model_routing()
225
+ )
226
+
227
+ self._budget_warned = False
228
+ self.session.messages.append({"role": "user", "content": user_input})
229
+ self._flush_pending_images()
230
+
231
+ edited = False
232
+ verified = False
233
+ first_pass = True
234
+ tool_turns = 0
235
+ while True:
236
+ if first_pass:
237
+ first_pass = False
238
+ else:
239
+ # Steering queued while the previous response streamed is
240
+ # injected before the next model call.
241
+ self._drain_steering()
242
+ try:
243
+ assistant_msg, tool_calls = await self._stream_once(
244
+ model_override=routed_model
245
+ )
246
+ except ProviderError as exc:
247
+ self.console.print(f"\n[red]{escape(str(exc))}[/red]")
248
+ self._emit("error", kind="provider", error=str(exc))
249
+ self._emit("done", reason="provider_error")
250
+ return
251
+
252
+ self.session.messages.append(assistant_msg)
253
+
254
+ if not tool_calls:
255
+ if edited and not verified and self._should_verify():
256
+ verified = True
257
+ await self._run_verification()
258
+ continue
259
+ if self._drain_steering():
260
+ # The model must answer the steered message.
261
+ continue
262
+ self._warn_budget()
263
+ self._emit("done", reason="stop")
264
+ return
265
+
266
+ if self.max_turns is not None and tool_turns >= self.max_turns:
267
+ self._emit("done", reason="max_turns", limit=self.max_turns)
268
+ return
269
+ edited = await self._handle_tool_calls(tool_calls) or edited
270
+ tool_turns += 1
271
+ self._warn_budget()
272
+
273
+ def _current_cost(self) -> float | None:
274
+ from kiwimatecoder.pricing import estimate_cost
275
+
276
+ return estimate_cost(
277
+ self.session.prompt_tokens,
278
+ self.session.completion_tokens,
279
+ self.session.model,
280
+ self.session.provider_id,
281
+ is_local=self.session.provider.is_local,
282
+ )
283
+
284
+ def _budget_exceeded(self) -> tuple[bool, str]:
285
+ """Whether a configured token/cost budget has already been spent."""
286
+ from kiwimatecoder.config import get_budget
287
+
288
+ budget = get_budget()
289
+ max_tokens = budget.get("max_tokens")
290
+ if max_tokens and self.session.total_tokens >= max_tokens:
291
+ return True, (
292
+ f"Budget reached: {self.session.total_tokens:,} tokens used "
293
+ f"(limit {int(max_tokens):,}). Raise it with "
294
+ "`/config budget tokens <n>` or clear it with `/config budget clear`."
295
+ )
296
+ max_cost = budget.get("max_cost_usd")
297
+ if max_cost:
298
+ cost = self._current_cost()
299
+ if cost is not None and cost >= max_cost:
300
+ return True, (
301
+ f"Budget reached: ~${cost:.4f} spent "
302
+ f"(limit ${max_cost:.4f}). Raise it with "
303
+ "`/config budget cost <usd>` or clear it with `/config budget clear`."
304
+ )
305
+ return False, ""
306
+
307
+ def _warn_budget(self) -> None:
308
+ """Print one warning when usage crosses 80% of a configured budget."""
309
+ if self._budget_warned:
310
+ return
311
+ from kiwimatecoder.config import get_budget
312
+
313
+ budget = get_budget()
314
+ max_tokens = budget.get("max_tokens")
315
+ if max_tokens and self.session.total_tokens >= 0.8 * max_tokens:
316
+ self._budget_warned = True
317
+ self.console.print(
318
+ f"[yellow]Budget warning: {self.session.total_tokens:,} of "
319
+ f"{int(max_tokens):,} tokens used.[/yellow]"
320
+ )
321
+ return
322
+ max_cost = budget.get("max_cost_usd")
323
+ if max_cost:
324
+ cost = self._current_cost()
325
+ if cost is not None and cost >= 0.8 * max_cost:
326
+ self._budget_warned = True
327
+ self.console.print(
328
+ f"[yellow]Budget warning: ~${cost:.4f} of "
329
+ f"${max_cost:.4f} spent.[/yellow]"
330
+ )
331
+
332
+ def _should_verify(self) -> bool:
333
+ return bool(
334
+ self.session.verify_command.strip()
335
+ and self.session.mode is not PermissionMode.PLAN
336
+ )
337
+
338
+ async def _run_verification(self) -> None:
339
+ """Run the configured verify command and feed its output back."""
340
+ command = self.session.verify_command.strip()
341
+ verify_tool = tools.get_tool("run_bash")
342
+ if verify_tool is None:
343
+ return
344
+ self.console.print(
345
+ f"\n[bold]Auto-verify:[/bold] [cyan]{escape(command)}[/cyan]"
346
+ )
347
+ t0 = time.perf_counter()
348
+ result = await asyncio.to_thread(
349
+ verify_tool.execute, {"command": command}, self.session
350
+ )
351
+ duration_ms = int((time.perf_counter() - t0) * 1000)
352
+ audit.record_tool_event(
353
+ tool="run_bash",
354
+ args={"command": command},
355
+ decision="auto_verify",
356
+ duration_ms=duration_ms,
357
+ ok=result.ok,
358
+ )
359
+ status = "[green]passed[/green]" if result.ok else "[red]failed[/red]"
360
+ self.console.print(f"Auto-verify {status} [dim]({duration_ms}ms)[/dim]")
361
+ content = result.content
362
+ if len(content) > 6000:
363
+ content = content[:6000] + "\n... [truncated]"
364
+ self.session.messages.append(
365
+ {
366
+ "role": "user",
367
+ "content": (
368
+ f"[auto-verify] `{command}` "
369
+ f"{'passed' if result.ok else 'failed'}:\n"
370
+ f"```text\n{content}\n```\n"
371
+ + (
372
+ "Fix the failures above, then re-run the verification."
373
+ if not result.ok
374
+ else "Briefly summarize what changed, then stop."
375
+ )
376
+ ),
377
+ }
378
+ )
379
+
380
+ def _tool_schemas(self) -> list[dict[str, Any]]:
381
+ """Tool schemas advertised for the next request.
382
+
383
+ In plan mode only read-only tools are advertised (the gate blocks the
384
+ rest as a second line of defense). Subagents never see ``task`` (no
385
+ nested subagents) or ``ask_user`` (no interactive user), and ``task``
386
+ is hidden for everyone while subagents are disabled. ``browser`` is
387
+ hidden until browser automation is enabled in config.
388
+ """
389
+ from kiwimatecoder.config import get_browser, get_subagents
390
+
391
+ schemas = tools.tool_schemas(
392
+ read_only=self.session.mode is PermissionMode.PLAN
393
+ )
394
+ excluded: set[str] = set()
395
+ if self.session.subagent:
396
+ excluded.update({"task", "ask_user"})
397
+ if not get_subagents()["enabled"]:
398
+ excluded.add("task")
399
+ if not get_browser()["enabled"]:
400
+ excluded.add("browser")
401
+ if excluded:
402
+ schemas = [
403
+ schema
404
+ for schema in schemas
405
+ if schema["function"]["name"] not in excluded
406
+ ]
407
+ return schemas
408
+
409
+ async def _stream_once(
410
+ self, model_override: str | None = None
411
+ ) -> tuple[dict[str, Any], list[AssembledToolCall]]:
412
+ """Stream one assistant response, rendering text and collecting tool calls.
413
+
414
+ Tries each active provider in order (primary first); when a provider
415
+ fails with a :class:`ProviderError`, the next active provider is tried
416
+ with the model chosen for it. A provider with no chosen model is
417
+ skipped (there are no default models). Only when every active provider
418
+ fails is the error surfaced.
419
+
420
+ ``model_override`` (per-turn model routing) applies to the primary
421
+ provider only; fallback providers keep their own model.
422
+ """
423
+ schemas = self._tool_schemas()
424
+
425
+ errors: list[ProviderError] = []
426
+ providers = self.session.active_providers
427
+ for index, provider in enumerate(providers):
428
+ try:
429
+ client = self._client(provider.id)
430
+ except ProviderError as exc:
431
+ errors.append(exc)
432
+ self._announce_failover(provider.name, exc, providers[index + 1 :])
433
+ continue
434
+ try:
435
+ model = self.session.model_for(provider.id)
436
+ if model_override and provider.id == self.session.provider_id:
437
+ if model_override != model:
438
+ self.console.print(
439
+ f"[dim]routed to {escape(model_override)}[/dim]"
440
+ )
441
+ model = model_override
442
+ if not model:
443
+ raise ProviderError(
444
+ f"no model chosen for {provider.name}; choose one with "
445
+ + (
446
+ "/model"
447
+ if provider.id == self.session.provider_id
448
+ else f"/config model set <model> {provider.id}"
449
+ )
450
+ )
451
+ return await self._stream_from(client, schemas, model)
452
+ except ProviderError as exc:
453
+ errors.append(exc)
454
+ self._announce_failover(provider.name, exc, providers[index + 1 :])
455
+ continue
456
+
457
+ if errors:
458
+ raise ProviderError(
459
+ "All active providers failed: "
460
+ + "; ".join(str(exc) for exc in errors)
461
+ )
462
+ raise ProviderError("No active providers are configured.")
463
+
464
+ def _announce_failover(
465
+ self,
466
+ provider_name: str,
467
+ exc: ProviderError,
468
+ remaining: list[Any],
469
+ ) -> None:
470
+ if not remaining:
471
+ return
472
+ self.console.print(
473
+ f"\n[yellow]{escape(provider_name)} failed ({escape(str(exc))}); "
474
+ f"trying {escape(str(remaining[0].name))}.[/yellow]"
475
+ )
476
+
477
+ async def _stream_from(
478
+ self,
479
+ client: UnifiedClient,
480
+ schemas: list[dict[str, Any]],
481
+ model: str,
482
+ ) -> tuple[dict[str, Any], list[AssembledToolCall]]:
483
+ """Stream from one client, rendering text and collecting tool calls."""
484
+ text_parts: list[str] = []
485
+ assembler = ToolCallAssembler()
486
+ printed_any = False
487
+ finish_reason: str | None = None
488
+ status: Any = (
489
+ self.console.status("[dim]Thinking…[/dim]")
490
+ if self.spinner_enabled
491
+ else _NoopStatus()
492
+ )
493
+ status.start()
494
+ thinking = True
495
+
496
+ try:
497
+ async for event in client.stream_chat(
498
+ self._request_messages(), schemas, model
499
+ ):
500
+ if isinstance(event, TextDelta):
501
+ if thinking:
502
+ status.stop()
503
+ thinking = False
504
+ if self.render_text:
505
+ self.console.print(
506
+ event.text, end="", markup=False, highlight=False
507
+ )
508
+ printed_any = True
509
+ self._emit("text_delta", text=event.text)
510
+ text_parts.append(event.text)
511
+ elif isinstance(event, ToolCallDelta):
512
+ assembler.add(event)
513
+ elif isinstance(event, Usage):
514
+ self.session.add_usage(event.prompt_tokens, event.completion_tokens)
515
+ self._emit(
516
+ "usage",
517
+ prompt_tokens=event.prompt_tokens,
518
+ completion_tokens=event.completion_tokens,
519
+ )
520
+ elif isinstance(event, Done):
521
+ finish_reason = event.finish_reason or finish_reason
522
+ except BaseException:
523
+ # Preserve the text the user already saw when a turn is interrupted
524
+ # (Ctrl-C) or the stream fails partway through.
525
+ partial = "".join(text_parts)
526
+ if partial:
527
+ partial_msg: dict[str, Any] = {
528
+ "role": "assistant",
529
+ "content": partial,
530
+ }
531
+ if (
532
+ not self.session.messages
533
+ or self.session.messages[-1] != partial_msg
534
+ ):
535
+ self.session.messages.append(partial_msg)
536
+ raise
537
+ finally:
538
+ if thinking:
539
+ status.stop()
540
+
541
+ if printed_any:
542
+ self.console.print()
543
+
544
+ calls = assembler.finalize()
545
+ if finish_reason == "length" and not calls and self.render_text:
546
+ # Some providers (KiwiMate caps replies at 1,024 tokens) stop
547
+ # mid-answer; say so rather than let the reply look complete.
548
+ self.console.print(
549
+ "[dim]The reply was cut off at the provider's output limit.[/dim]"
550
+ )
551
+ assistant_msg: dict[str, Any] = {"role": "assistant", "content": "".join(text_parts) or None}
552
+ if calls:
553
+ assistant_msg["tool_calls"] = [
554
+ {
555
+ "id": c.id,
556
+ "type": "function",
557
+ "function": {"name": c.name, "arguments": c.arguments},
558
+ }
559
+ for c in calls
560
+ ]
561
+ return assistant_msg, calls
562
+
563
+ def _auto_confirm(self, summary: str, preview: str | None) -> bool:
564
+ """Approval stand-in used in dry-run mode so nothing prompts."""
565
+ return True
566
+
567
+ def _format_call_summary(self, name: str, args: dict[str, Any]) -> str:
568
+ """Produce a short human-readable string summarizing the tool call arguments.
569
+
570
+ The result is Rich markup, and every value in it comes from the model
571
+ (a path, a command, a pattern), so each one is escaped: ``app/[slug]/page.tsx``
572
+ must not lose its ``[slug]`` and ``test_a[/tmp/x]`` must not raise.
573
+ """
574
+
575
+ def clip(value: object, limit: int) -> str:
576
+ text = str(value or "")
577
+ return escape(text if len(text) <= limit else f"{text[: limit - 3]}...")
578
+
579
+ if name in ("read_file", "write_file", "edit_file", "list_dir"):
580
+ target = args.get("path", ".")
581
+ return f"{escape(name)} [dim]{escape(str(target))}[/dim]"
582
+ if name == "view_image":
583
+ target = args.get("path", ".")
584
+ return f"image [dim]{escape(str(target))}[/dim]"
585
+ if name == "generate_image":
586
+ return f"image-gen [dim]{clip(args.get('prompt'), 50)}[/dim]"
587
+ if name == "generate_video":
588
+ prompt = str(args.get("prompt", "") or "")
589
+ if not prompt and args.get("resume_job_id"):
590
+ prompt = f"resume {args['resume_job_id']}"
591
+ return f"video-gen [dim]{clip(prompt, 50)}[/dim]"
592
+ if name == "search":
593
+ pat = args.get("pattern", "")
594
+ mode = args.get("mode", "grep")
595
+ return f"search [dim]{escape(str(pat))}[/dim] ({escape(str(mode))})"
596
+ if name == "run_bash":
597
+ return f"bash [dim]`{clip(args.get('command'), 40)}`[/dim]"
598
+ if name == "shell":
599
+ return f"shell [dim]`{clip(args.get('command'), 40)}`[/dim]"
600
+ if name == "shell_jobs":
601
+ action = str(args.get("action", "") or "")
602
+ short = clip(args.get("command") or args.get("id"), 40)
603
+ suffix = f" [dim]{short}[/dim]" if short else ""
604
+ return f"shell_jobs [dim]{escape(action)}[/dim]{suffix}"
605
+ if name == "update_todos":
606
+ todos = args.get("todos")
607
+ count = len(todos) if isinstance(todos, list) else 0
608
+ return f"todos [dim]({count} item{'s' if count != 1 else ''})[/dim]"
609
+ if name == "ask_user":
610
+ return f"ask [dim]{clip(args.get('question'), 50)}[/dim]"
611
+ if name == "load_skill":
612
+ return f"skill [dim]{escape(str(args.get('name', '') or ''))}[/dim]"
613
+ if name == "web_fetch":
614
+ return f"web_fetch [dim]{clip(args.get('url'), 60)}[/dim]"
615
+ if name == "web_search":
616
+ return f"web_search [dim]{clip(args.get('query'), 50)}[/dim]"
617
+ if name in ("git", "git_write", "forge", "forge_write"):
618
+ action = str(args.get("action", "") or "")
619
+ return f"{escape(name)} [dim]{escape(action)}[/dim]"
620
+ if name == "remember":
621
+ return "memory [dim]remember[/dim]"
622
+ if name == "recall":
623
+ short = clip(args.get("query"), 40)
624
+ return f"memory [dim]recall {short}[/dim]" if short else "memory [dim]recall[/dim]"
625
+ if name == "read_clipboard":
626
+ return "clipboard [dim]read[/dim]"
627
+ if name == "write_clipboard":
628
+ return f"clipboard [dim]write {clip(args.get('text'), 40)}[/dim]"
629
+ if name in ("http_get", "http_request"):
630
+ short = clip(args.get("url"), 60)
631
+ method = str(args.get("method", "") or "GET").upper()
632
+ label = "GET" if name == "http_get" else method
633
+ return f"http [dim]{escape(label)} {short}[/dim]"
634
+ if name in ("lsp_diagnostics", "lsp_definition", "lsp_references"):
635
+ action = name[len("lsp_") :]
636
+ path = str(args.get("path", "") or "")
637
+ if action == "diagnostics" and not path:
638
+ return "lsp [dim]diagnostics recent files[/dim]"
639
+ return f"lsp [dim]{action} {escape(path)}[/dim]"
640
+ if name == "task":
641
+ return f"task [dim]{clip(args.get('description'), 50)}[/dim]"
642
+ if name == "browser":
643
+ action = str(args.get("action", "") or "")
644
+ target = clip(
645
+ args.get("url")
646
+ or args.get("selector")
647
+ or args.get("script")
648
+ or args.get("text"),
649
+ 60,
650
+ )
651
+ detail = f"{escape(action)} {target}".strip()
652
+ return f"browser [dim]{detail}[/dim]"
653
+ return escape(name)
654
+
655
+ # Only purely read-only tools are safe to run concurrently: they do not
656
+ # mutate session state or the workspace.
657
+ _PARALLEL_SAFE = frozenset({"read_file", "list_dir", "search"})
658
+
659
+ def _can_run_parallel(self, calls: list[AssembledToolCall]) -> bool:
660
+ if len(calls) < 2:
661
+ return False
662
+ for call in calls:
663
+ tool = tools.get_tool(call.name)
664
+ if tool is None or tool.needs_approval or call.name not in self._PARALLEL_SAFE:
665
+ return False
666
+ return True
667
+
668
+ async def _handle_tool_calls(self, calls: list[AssembledToolCall]) -> bool:
669
+ """Run a batch of tool calls; returns whether any file edit succeeded."""
670
+ results: list[tuple[dict[str, Any], bool]]
671
+ if self._can_run_parallel(calls):
672
+ results = await asyncio.gather(
673
+ *(asyncio.to_thread(self._run_tool_call, call) for call in calls)
674
+ )
675
+ else:
676
+ results = []
677
+ for call in calls:
678
+ # ``task`` drives a nested async loop, so it cannot go through
679
+ # the synchronous runner (or a worker thread).
680
+ if call.name == "task":
681
+ results.append(await self._run_task_call(call))
682
+ else:
683
+ results.append(self._run_tool_call(call))
684
+ self.session.messages.extend(message for message, _edited in results)
685
+ # Images attached by this batch go after its tool results (and only
686
+ # once, even when several calls attached one).
687
+ self._flush_pending_images()
688
+ return any(edited for _message, edited in results)
689
+
690
+ def _tool_message(self, tool_call_id: str, content: str) -> dict[str, Any]:
691
+ return {"role": "tool", "tool_call_id": tool_call_id, "content": content}
692
+
693
+ def _prepare_tool_call(
694
+ self, call: AssembledToolCall
695
+ ) -> tuple[_PreparedCall | None, tuple[dict[str, Any], bool] | None]:
696
+ """Parse, gate, hook-check, and checkpoint one tool call.
697
+
698
+ Returns ``(prepared, None)`` when the call may execute, or
699
+ ``(None, early_result)`` with the tool message to return instead
700
+ (unknown tool, bad JSON, denied, dry-run, or hook-blocked).
701
+ """
702
+ tool = tools.get_tool(call.name)
703
+ if tool is None:
704
+ return None, (
705
+ self._tool_message(call.id, f"Error: unknown tool '{call.name}'"),
706
+ False,
707
+ )
708
+ if call.name == "task" and self.session.subagent:
709
+ return None, (
710
+ self._tool_message(
711
+ call.id, "Error: subagents cannot spawn nested subagents."
712
+ ),
713
+ False,
714
+ )
715
+
716
+ try:
717
+ args = call.parse_arguments()
718
+ except json.JSONDecodeError as exc:
719
+ return None, (
720
+ self._tool_message(
721
+ call.id, f"Error: could not parse arguments as JSON: {exc}"
722
+ ),
723
+ False,
724
+ )
725
+
726
+ summary = self._format_call_summary(call.name, args)
727
+ preview_text = tools.preview(call.name, args, self.session)
728
+ confirm = (
729
+ self._auto_confirm
730
+ if self.session.dry_run and tool.needs_approval
731
+ else self.confirm
732
+ )
733
+ decision = gate(tool, args, self.session, confirm, preview_text)
734
+ if not decision.allowed:
735
+ audit.record_tool_event(
736
+ tool=call.name,
737
+ args=args,
738
+ decision="denied",
739
+ reason=decision.reason,
740
+ )
741
+ self._print_markup(
742
+ f"[yellow]{self._glyph('blocked')} {summary}: "
743
+ f"{escape(decision.reason)}[/yellow]"
744
+ )
745
+ return None, (self._tool_message(call.id, decision.reason), False)
746
+
747
+ if self.session.dry_run and tool.needs_approval:
748
+ audit.record_tool_event(tool=call.name, args=args, decision="dry_run")
749
+ self.console.print(f"[yellow]dry-run[/yellow] {summary}")
750
+ if preview_text:
751
+ self.console.print(preview_text, markup=False, highlight=False)
752
+ return None, (
753
+ self._tool_message(
754
+ call.id,
755
+ f"DRY RUN: {summary} was not executed; no files or state changed.",
756
+ ),
757
+ False,
758
+ )
759
+
760
+ original_args = args
761
+ partial_hunks: tuple[int, ...] | None = None
762
+ if decision.selected_hunks is not None:
763
+ selected_args = (
764
+ tools.select_hunks(
765
+ call.name, args, self.session, decision.selected_hunks
766
+ )
767
+ if call.name in ("write_file", "edit_file")
768
+ else None
769
+ )
770
+ if selected_args is None:
771
+ reason = (
772
+ "Partial hunk selection could not be applied; no changes "
773
+ "were made."
774
+ )
775
+ audit.record_tool_event(
776
+ tool=call.name,
777
+ args=original_args,
778
+ decision="denied",
779
+ reason=reason,
780
+ hunks=decision.selected_hunks,
781
+ )
782
+ self._print_markup(
783
+ f"[yellow]{self._glyph('blocked')} {summary}: "
784
+ f"{escape(reason)}[/yellow]"
785
+ )
786
+ return None, (self._tool_message(call.id, reason), False)
787
+ args = selected_args
788
+ partial_hunks = decision.selected_hunks
789
+
790
+ pre_results = self._run_pre_tool_hooks(call.name, args)
791
+ blocked = next((item for item in pre_results if item.blocked), None)
792
+ if blocked is not None:
793
+ reason = self._hook_block_reason(blocked)
794
+ audit.record_tool_event(
795
+ tool=call.name,
796
+ args=original_args,
797
+ decision="hook_blocked",
798
+ reason=reason,
799
+ )
800
+ self._print_markup(
801
+ f"[red]{self._glyph('blocked')} {summary}: {escape(reason)}[/red]"
802
+ )
803
+ return None, (
804
+ self._tool_message(call.id, self._hook_block_message(blocked)),
805
+ False,
806
+ )
807
+
808
+ if call.name in ("write_file", "edit_file"):
809
+ path = str(args.get("path") or "").strip()
810
+ if path:
811
+ self.session.checkpoint([path], f"{call.name} {path}")
812
+
813
+ return (
814
+ _PreparedCall(
815
+ call=call,
816
+ tool=tool,
817
+ args=args,
818
+ original_args=original_args,
819
+ summary=summary,
820
+ partial_hunks=partial_hunks,
821
+ ),
822
+ None,
823
+ )
824
+
825
+ def _run_tool_call(
826
+ self, call: AssembledToolCall
827
+ ) -> tuple[dict[str, Any], bool]:
828
+ """Execute one tool call; returns (tool message, edited_file)."""
829
+ prepared, early = self._prepare_tool_call(call)
830
+ if early is not None:
831
+ return early
832
+ assert prepared is not None
833
+ self._emit(
834
+ "tool_start",
835
+ tool=prepared.call.name,
836
+ args=self._redacted_args(prepared.args),
837
+ )
838
+ if self.output_mode == "verbose":
839
+ self.console.print(self._verbose_args(prepared.args))
840
+
841
+ t0 = time.perf_counter()
842
+ try:
843
+ with (
844
+ self.console.status(f"{prepared.summary}…")
845
+ if self.spinner_enabled
846
+ else nullcontext()
847
+ ):
848
+ result = prepared.tool.execute(prepared.args, self.session)
849
+ except Exception as exc:
850
+ result = ToolResult.error(f"Tool crashed: {exc!r}")
851
+ return self._finish_tool_call(prepared, result, t0)
852
+
853
+ async def _run_task_call(
854
+ self, call: AssembledToolCall
855
+ ) -> tuple[dict[str, Any], bool]:
856
+ """Execute a ``task`` call, awaiting the nested subagent loop."""
857
+ prepared, early = self._prepare_tool_call(call)
858
+ if early is not None:
859
+ return early
860
+ assert prepared is not None
861
+ self._emit(
862
+ "tool_start",
863
+ tool=prepared.call.name,
864
+ args=self._redacted_args(prepared.args),
865
+ )
866
+ if self.output_mode == "verbose":
867
+ self.console.print(self._verbose_args(prepared.args))
868
+
869
+ t0 = time.perf_counter()
870
+ try:
871
+ with (
872
+ self.console.status(f"{prepared.summary}…")
873
+ if self.spinner_enabled
874
+ else nullcontext()
875
+ ):
876
+ result = await self._run_task(prepared.args)
877
+ except Exception as exc:
878
+ result = ToolResult.error(f"Tool crashed: {exc!r}")
879
+ return self._finish_tool_call(prepared, result, t0)
880
+
881
+ def _finish_tool_call(
882
+ self,
883
+ prepared: _PreparedCall,
884
+ result: ToolResult,
885
+ started_at: float,
886
+ ) -> tuple[dict[str, Any], bool]:
887
+ """Record, render, and package the outcome of a prepared call."""
888
+ duration_ms = int((time.perf_counter() - started_at) * 1000)
889
+ decision_value = (
890
+ "allowed_partial" if prepared.partial_hunks is not None else "allowed"
891
+ )
892
+ audit.record_tool_event(
893
+ tool=prepared.call.name,
894
+ args=prepared.original_args,
895
+ decision=decision_value,
896
+ duration_ms=duration_ms,
897
+ ok=result.ok,
898
+ hunks=prepared.partial_hunks,
899
+ )
900
+ self._run_post_tool_hooks(
901
+ prepared.call.name,
902
+ prepared.original_args,
903
+ result.ok,
904
+ duration_ms,
905
+ decision_value,
906
+ )
907
+ self._emit(
908
+ "tool_end",
909
+ tool=prepared.call.name,
910
+ ok=result.ok,
911
+ duration_ms=duration_ms,
912
+ )
913
+
914
+ if result.ok:
915
+ if self.output_mode != "compact":
916
+ self._print_markup(
917
+ f"[bold green]{self._glyph('check')}[/bold green] "
918
+ f"{prepared.summary} [dim]({duration_ms}ms)[/dim]"
919
+ )
920
+ else:
921
+ self._print_markup(
922
+ f"[bold red]{self._glyph('cross')}[/bold red] "
923
+ f"{prepared.summary} [red](failed)[/red] [dim]({duration_ms}ms)[/dim]"
924
+ )
925
+ if self.output_mode == "verbose":
926
+ self.console.print(f"[dim]result: {len(result.content):,} chars[/dim]")
927
+ edited = prepared.call.name in ("write_file", "edit_file") and result.ok
928
+ content = result.content
929
+ if result.ok and prepared.partial_hunks is not None:
930
+ content += (
931
+ f" (applied hunks: {', '.join(str(h) for h in prepared.partial_hunks)})"
932
+ )
933
+ if edited:
934
+ content += self._post_edit_diagnostics(prepared.args)
935
+ return self._tool_message(prepared.call.id, content), edited
936
+
937
+ def _build_child_session(self, model: str) -> Session:
938
+ """Create the isolated session a subagent runs in.
939
+
940
+ The child inherits the workspace, provider roster/model, permission
941
+ mode, rules, and trust settings, but starts with empty conversation
942
+ history, context, todos, checkpoints, and steering.
943
+ """
944
+ parent = self.session
945
+ return Session(
946
+ provider_id=parent.provider_id,
947
+ model=model,
948
+ mode=parent.mode,
949
+ workspace_root=parent.workspace_root,
950
+ always_allowed=set(parent.always_allowed),
951
+ active_provider_ids=list(parent.active_provider_ids),
952
+ models=dict(parent.models),
953
+ output_style=parent.output_style,
954
+ custom_system_prompt=parent.custom_system_prompt,
955
+ command_rules={
956
+ kind: list(patterns)
957
+ for kind, patterns in parent.command_rules.items()
958
+ },
959
+ dry_run=parent.dry_run,
960
+ trusted_workspace=parent.trusted_workspace,
961
+ compact_at_tokens=parent.compact_at_tokens,
962
+ context_window=parent.context_window,
963
+ subagent=True,
964
+ )
965
+
966
+ async def _run_task(self, args: dict[str, Any]) -> ToolResult:
967
+ """Run one subagent to completion and return its final report."""
968
+ from kiwimatecoder.config import get_budget, get_subagents
969
+
970
+ settings = get_subagents()
971
+ if not settings["enabled"]:
972
+ return ToolResult.error(
973
+ "Subagents are disabled. Enable them with "
974
+ "`/config subagents enable on`."
975
+ )
976
+ prompt = str(args.get("prompt") or "").strip()
977
+ if not prompt:
978
+ return ToolResult.error("'prompt' is required")
979
+
980
+ token_cap: int | None = None
981
+ max_tokens = get_budget().get("max_tokens")
982
+ if max_tokens:
983
+ remaining = int(max_tokens) - self.session.total_tokens
984
+ if remaining <= 0:
985
+ return ToolResult.error(
986
+ "The token budget is already exhausted; raise it with "
987
+ "`/config budget tokens <n>` before delegating."
988
+ )
989
+ token_cap = remaining
990
+
991
+ model = (
992
+ str(args.get("model") or "").strip()
993
+ or str(settings["model"])
994
+ or self.session.model
995
+ )
996
+ max_steps = int(settings["max_steps"])
997
+ child_session = self._build_child_session(model)
998
+ child = Agent(
999
+ child_session,
1000
+ TaskConsole(self.console),
1001
+ self.confirm,
1002
+ bus=self.bus,
1003
+ ascii_mode=self.ascii_mode,
1004
+ output_mode=self.output_mode,
1005
+ spinner=self.spinner,
1006
+ )
1007
+
1008
+ child_session.messages.append({"role": "user", "content": prompt})
1009
+ steps = 0
1010
+ last_text = ""
1011
+ last_state = ""
1012
+ stop_note = ""
1013
+ try:
1014
+ while True:
1015
+ try:
1016
+ assistant_msg, tool_calls = await child._stream_once()
1017
+ except ProviderError as exc:
1018
+ return ToolResult.error(f"Subagent failed: {exc}")
1019
+ child_session.messages.append(assistant_msg)
1020
+ if assistant_msg.get("content"):
1021
+ last_text = str(assistant_msg["content"])
1022
+ if not tool_calls:
1023
+ break
1024
+ await child._handle_tool_calls(tool_calls)
1025
+ steps += 1
1026
+ last_message = child_session.messages[-1]
1027
+ if last_message.get("role") == "tool":
1028
+ last_state = str(last_message.get("content") or "")
1029
+ if steps >= max_steps:
1030
+ stop_note = f"step limit ({max_steps}) reached"
1031
+ break
1032
+ if token_cap is not None and child_session.total_tokens >= token_cap:
1033
+ stop_note = (
1034
+ f"inherited token budget ({token_cap:,} tokens) reached"
1035
+ )
1036
+ break
1037
+ finally:
1038
+ # Subagent usage counts against the parent's budget and its edits
1039
+ # stay undoable from the parent conversation.
1040
+ self.session.add_usage(
1041
+ child_session.prompt_tokens, child_session.completion_tokens
1042
+ )
1043
+ for path in child_session.touched_files:
1044
+ self.session.record_touched(path)
1045
+ if child_session.checkpoints:
1046
+ self.session.checkpoints.extend(child_session.checkpoints)
1047
+ if self.session.checkpoint_store is None:
1048
+ self.session.checkpoint_store = child_session.checkpoint_store
1049
+
1050
+ report = last_text.strip() or last_state.strip() or "(no final answer)"
1051
+ if stop_note:
1052
+ report += f"\n\n[task stopped: {stop_note}; partial report]"
1053
+ content = (
1054
+ f"[task result]\n{report}\n\n"
1055
+ f"[task: {steps} step(s), {child_session.total_tokens:,} token(s)]"
1056
+ )
1057
+ if len(content) > TASK_RESULT_MAX_CHARS:
1058
+ content = content[:TASK_RESULT_MAX_CHARS] + "\n... [truncated]"
1059
+ return ToolResult(content=content)
1060
+
1061
+ def _post_edit_diagnostics(self, args: dict[str, Any]) -> str:
1062
+ """Return ``LSP:`` lines for an edited file, or an empty string.
1063
+
1064
+ Best-effort and bounded: disabled config, a missing server, a timeout,
1065
+ or any other failure appends nothing and never delays past the
1066
+ configured timeout (capped further for post-edit latency).
1067
+ """
1068
+ from kiwimatecoder import config, lsp
1069
+ from kiwimatecoder.tools.lsp import format_diagnostic
1070
+
1071
+ path = str(args.get("path") or "").strip()
1072
+ if not path:
1073
+ return ""
1074
+ try:
1075
+ settings = config.get_lsp()
1076
+ if not (settings["enabled"] and settings["diagnostics_after_edits"]):
1077
+ return ""
1078
+ target = Path(path)
1079
+ if not target.is_absolute():
1080
+ target = self.session.workspace_root / target
1081
+ manager = lsp.get_manager()
1082
+ if manager is None:
1083
+ manager = lsp.ensure_manager(root=self.session.workspace_root)
1084
+ timeout = min(float(settings["timeout"]), POST_EDIT_DIAGNOSTICS_TIMEOUT)
1085
+ diagnostics = manager.diagnostics_for(str(target), timeout=timeout)
1086
+ except Exception:
1087
+ return ""
1088
+ if not diagnostics:
1089
+ return ""
1090
+ shown = diagnostics[:MAX_EDIT_DIAGNOSTICS]
1091
+ lines = [f"LSP: {format_diagnostic(path, item)}" for item in shown]
1092
+ hidden = len(diagnostics) - len(shown)
1093
+ if hidden > 0:
1094
+ lines.append(f"LSP: … {hidden} more diagnostic(s)")
1095
+ return "\n" + "\n".join(lines)
1096
+
1097
+ def _verbose_args(self, args: dict[str, Any]) -> str:
1098
+ """Render the verbose-mode argument block: redacted and capped."""
1099
+ try:
1100
+ rendered = json.dumps(args, ensure_ascii=False, sort_keys=True, default=str)
1101
+ except (TypeError, ValueError):
1102
+ rendered = str(args)
1103
+ rendered = redact(rendered)
1104
+ if len(rendered) > 500:
1105
+ rendered = rendered[:497] + "..."
1106
+ return f"[dim]args: {escape(rendered)}[/dim]"
1107
+
1108
+ def _run_pre_tool_hooks(
1109
+ self, name: str, args: dict[str, Any]
1110
+ ) -> list[hooks.HookResult]:
1111
+ """Emit PRE_TOOL and run configured pre-tool shell hooks."""
1112
+ self.bus.emit(events.PRE_TOOL, tool=name, args=args)
1113
+ return hooks.run_hooks(
1114
+ events.PRE_TOOL,
1115
+ session=self.session,
1116
+ console=self.console,
1117
+ tool_name=name,
1118
+ tool_args=args,
1119
+ )
1120
+
1121
+ def _run_post_tool_hooks(
1122
+ self,
1123
+ name: str,
1124
+ args: dict[str, Any],
1125
+ ok: bool,
1126
+ duration_ms: int,
1127
+ decision: str,
1128
+ ) -> None:
1129
+ """Emit POST_TOOL and run configured post-tool shell hooks."""
1130
+ self.bus.emit(
1131
+ events.POST_TOOL,
1132
+ tool=name,
1133
+ args=args,
1134
+ ok=ok,
1135
+ duration_ms=duration_ms,
1136
+ decision=decision,
1137
+ )
1138
+ hooks.run_hooks(
1139
+ events.POST_TOOL,
1140
+ session=self.session,
1141
+ console=self.console,
1142
+ tool_name=name,
1143
+ tool_args=args,
1144
+ ok=ok,
1145
+ duration_ms=duration_ms,
1146
+ )
1147
+
1148
+ def _hook_block_reason(self, result: hooks.HookResult) -> str:
1149
+ detail = "timed out" if result.timed_out else f"exit code {result.exit_code}"
1150
+ return f"blocked by pre_tool hook ({detail}): {redact(result.command)}"
1151
+
1152
+ def _hook_block_message(self, result: hooks.HookResult) -> str:
1153
+ message = (
1154
+ f"Error: {self._hook_block_reason(result)}. The action was not "
1155
+ "executed. Fix the problem the hook reported, then try again."
1156
+ )
1157
+ output = redact(result.output.strip())
1158
+ if output:
1159
+ if len(output) > 2000:
1160
+ output = output[:2000] + "\n... [truncated]"
1161
+ message += f"\n{output}"
1162
+ return message