autoforge-cli 0.1.0a0__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 (81) hide show
  1. autoforge/__init__.py +5 -0
  2. autoforge/agent_worktree.py +653 -0
  3. autoforge/certificate.py +495 -0
  4. autoforge/child_env.py +871 -0
  5. autoforge/claims.py +726 -0
  6. autoforge/claude_stream.py +501 -0
  7. autoforge/cli.py +2884 -0
  8. autoforge/config.py +2178 -0
  9. autoforge/config_fingerprint.py +374 -0
  10. autoforge/doctor.py +1551 -0
  11. autoforge/doctor_sandbox.py +691 -0
  12. autoforge/effect_ops.py +1272 -0
  13. autoforge/effects.py +3210 -0
  14. autoforge/engine.py +16637 -0
  15. autoforge/epic_commands.py +1486 -0
  16. autoforge/epic_graph.py +852 -0
  17. autoforge/errors.py +536 -0
  18. autoforge/evidence.py +1722 -0
  19. autoforge/executor.py +1601 -0
  20. autoforge/executor_duplex.py +985 -0
  21. autoforge/executor_supervised.py +144 -0
  22. autoforge/git_transport.py +970 -0
  23. autoforge/github.py +3402 -0
  24. autoforge/interaction.py +1027 -0
  25. autoforge/local_workspace.py +1253 -0
  26. autoforge/locking.py +1050 -0
  27. autoforge/loop_detect.py +660 -0
  28. autoforge/loop_guard.py +404 -0
  29. autoforge/materialize.py +722 -0
  30. autoforge/ownership.py +240 -0
  31. autoforge/pi_rpc.py +713 -0
  32. autoforge/premerge.py +181 -0
  33. autoforge/profiles.py +106 -0
  34. autoforge/progress.py +392 -0
  35. autoforge/prompts/__init__.py +173 -0
  36. autoforge/prompts/acceptance_actions.md +39 -0
  37. autoforge/prompts/analyze_execute.md +128 -0
  38. autoforge/prompts/common.md +132 -0
  39. autoforge/prompts/correction.md +12 -0
  40. autoforge/prompts/fix.md +252 -0
  41. autoforge/prompts/local_analyze_execute.md +69 -0
  42. autoforge/prompts/local_common.md +149 -0
  43. autoforge/prompts/local_fix.md +96 -0
  44. autoforge/prompts/local_review.md +103 -0
  45. autoforge/prompts/operator_acceptance.md +61 -0
  46. autoforge/prompts/replan_reexecute.md +991 -0
  47. autoforge/prompts/review.md +312 -0
  48. autoforge/prompts/update_epic.md +120 -0
  49. autoforge/prompts/update_epic_rerequest.md +85 -0
  50. autoforge/prompts/validation_evidence.md +26 -0
  51. autoforge/prompts/validation_failure.md +22 -0
  52. autoforge/prompts/worktree_state.md +45 -0
  53. autoforge/providers.py +2893 -0
  54. autoforge/read_context.py +389 -0
  55. autoforge/redaction.py +408 -0
  56. autoforge/replan.py +208 -0
  57. autoforge/replan_txn.py +1930 -0
  58. autoforge/result_parser.py +2234 -0
  59. autoforge/roadmap.py +151 -0
  60. autoforge/run_contract.py +408 -0
  61. autoforge/run_namespace.py +839 -0
  62. autoforge/runlog.py +764 -0
  63. autoforge/safefs.py +1717 -0
  64. autoforge/sandbox.py +1313 -0
  65. autoforge/sandbox_bwrap.py +815 -0
  66. autoforge/sandbox_pin.py +170 -0
  67. autoforge/sandbox_policy.py +1107 -0
  68. autoforge/scheduler.py +1143 -0
  69. autoforge/scheduler_state.py +2316 -0
  70. autoforge/scheduler_stop.py +421 -0
  71. autoforge/sdk_policy.py +988 -0
  72. autoforge/state.py +2417 -0
  73. autoforge/transitions.py +388 -0
  74. autoforge/validation.py +324 -0
  75. autoforge/worker.py +568 -0
  76. autoforge_cli-0.1.0a0.dist-info/METADATA +525 -0
  77. autoforge_cli-0.1.0a0.dist-info/RECORD +81 -0
  78. autoforge_cli-0.1.0a0.dist-info/WHEEL +5 -0
  79. autoforge_cli-0.1.0a0.dist-info/entry_points.txt +2 -0
  80. autoforge_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
  81. autoforge_cli-0.1.0a0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,501 @@
1
+ """Claude Code stream-json protocol (#192): lines in, progress out, one outcome.
2
+
3
+ :class:`ClaudeStream` is the whole of one ``claude -p --output-format
4
+ stream-json --verbose`` invocation as a pure reducer, the way
5
+ :class:`autoforge.pi_rpc.PiConversation` is Pi's. The driver
6
+ (:meth:`autoforge.providers.ClaudeCodeProvider.execute`) feeds it every
7
+ stdout line as bytes, reports a line past the per-line bound, an
8
+ unterminated last line, an overflow and the end of stdout; nothing here
9
+ starts a process, knows a CLI flag or reads a clock. The module knows
10
+ Claude's record names and nothing else, so its tests drive it from
11
+ synthetic lines with no process at all.
12
+
13
+ What a stream is (verified on claude 2.1.292, 2026-10-06): one JSON object
14
+ per line. ``system`` records (``init``, which names the model;
15
+ ``thinking_tokens``, which carries ``estimated_tokens`` and is emitted
16
+ repeatedly while the model is still thinking; ``hook_started`` and
17
+ ``hook_response`` from the operator's own hooks, which quote the hook's
18
+ output and are never shown), ``assistant`` messages whose content blocks
19
+ are ``thinking``, ``text`` or ``tool_use`` (``id``, ``name``, ``input``),
20
+ ``user`` messages carrying ``tool_result`` blocks (``tool_use_id``,
21
+ ``is_error``), ``rate_limit_event``, and a ``result`` record that ends the
22
+ turn. ``system/api_retry`` (``attempt``, ``max_retries``) is mapped too, from
23
+ the CLI's documented message set; it did not occur in the verified sample.
24
+
25
+ One invocation can run more than one turn (verified on claude 2.1.293,
26
+ 2026-10-07): a background task or a scheduled wakeup still pending when a
27
+ turn ends keeps the CLI alive, and when it fires the CLI runs another turn,
28
+ from its own ``system/init`` to its own ``result`` (``result_index`` 0, 1,
29
+ ...), with notices such as ``system/task_notification`` or
30
+ ``command_lifecycle`` between turns. ``--output-format text`` prints only
31
+ the last turn's result text, at exit. A turn that ends while a background
32
+ agent (``Agent`` run in the background) is still running does not get its
33
+ ``result`` then: the CLI holds it back, the next turn opens with its own
34
+ ``system/init`` all the same, and once no background agent is left the
35
+ held results arrive together, in turn order, after the last turn's records
36
+ (verified on claude 2.1.293, 2026-10-07). A turn that ends with only a
37
+ background shell task pending gets its ``result`` at once. Either way the
38
+ n-th ``result`` belongs to the n-th turn. A background agent's own records
39
+ reach the stream too, each with the ``parent_tool_use_id`` of the call
40
+ that started the agent, but only as ``assistant`` and ``user`` records; the
41
+ agent's start and end are main-conversation notices (``system/task_*``),
42
+ and every ``system/init`` and ``result`` is the main conversation's
43
+ (verified on claude 2.1.293, 2026-10-07, with three background agents).
44
+ The turn count rests on that: a subagent's own ``system/init`` or
45
+ ``result`` would throw it off.
46
+
47
+ The outcome:
48
+
49
+ * The last ``result`` record's ``result`` string is the final assistant
50
+ text, verbatim once decoded: it becomes the invocation's stdout, so the
51
+ CONTROL_RESULT block is parsed exactly as it was from ``--output-format
52
+ text``. Every ``result`` is checked: ``is_error`` first (``subtype:
53
+ success`` can come with ``is_error: true``), then ``subtype`` must be
54
+ ``success``, then ``result`` must be a string. Anything else is a provider
55
+ failure, also in a turn a later one would have followed.
56
+ * A line that is not a JSON object with a string ``type`` (including one
57
+ the decoder refuses for an integer or nesting limit), an unterminated
58
+ last line, a queue overflow, a ``result`` outside a turn (one with every
59
+ turn so far closed), and stdout ending before every turn has its
60
+ ``result`` are provider failures too. The first turn opens with the
61
+ stream; a later one opens with its own ``system/init`` or, with every
62
+ turn so far closed, with an ``assistant`` or ``user`` record, whose
63
+ ``system/init`` is then that turn's own. A turn's ``result`` closes it.
64
+ * A line past the per-line bound is counted and skipped: a ``tool_result``
65
+ carries whole files, and progress is not worth failing a run for. The
66
+ ``result`` line has no such allowance; if it was the line skipped, the
67
+ stream ends without one and fails, the way Pi's final text must arrive
68
+ whole in one record (ADR 0003).
69
+ * Records that arrive with every turn closed and open no turn (notices
70
+ between turns, the operator's ``SessionEnd`` hooks after the last one)
71
+ are counted and otherwise ignored.
72
+
73
+ Progress: each record becomes at least one :class:`~autoforge.progress.ProgressEvent`
74
+ (liveness for #193), and the only strings an event carries are the model
75
+ name and one allow-listed input per tool (:data:`TOOL_DETAILS`) -- never a
76
+ command line, a tool result, thinking or assistant text. The summary is
77
+ flat bounded scalars from the ``result`` records (no nested ``usage`` or
78
+ ``modelUsage``): the last one's, with ``num_turns`` and ``duration_ms``,
79
+ which each ``result`` reports for its own turn, summed over all of them, and
80
+ their count; ``total_cost_usd`` is the session's so far, so the last one's
81
+ is the invocation's. The cost is integer micro-USD, and a value that is
82
+ absent or unreadable is left out rather than reported as zero. So is a sum
83
+ once one ``result`` lacks its count: what the others add up to is not the
84
+ invocation's total.
85
+
86
+ Loop detection (#194): given a :class:`~autoforge.loop_detect.LoopObserver`,
87
+ each completed tool call is reported as an action fingerprinted from its
88
+ name, the digest of its whole ``input`` (kept from the ``tool_use`` until
89
+ its ``tool_result`` arrives) and the digest of the whole ``tool_result``
90
+ block but its ``tool_use_id``; ``system/api_retry`` is a retry and an
91
+ ``assistant`` record a completed turn. Only names and digests leave here.
92
+
93
+ Refused requests (#258, run on claude 2.1.312 against a loopback stand-in
94
+ of the Messages API, 2026-10-27): a ``-p`` run asks no one. A tool call
95
+ that needs a permission its mode does not grant (``manual``, ``dontAsk``,
96
+ ``acceptEdits`` for a command) or that a deny rule of the user settings
97
+ names is refused at once: a ``system/permission_denied`` record (``tool_name``,
98
+ ``tool_use_id``), an error ``tool_result`` telling the model to go on
99
+ without it, and the call listed in the turn's ``result`` under
100
+ ``permission_denials`` (``tool_name``, ``tool_use_id``, ``tool_input``); the
101
+ run goes on and exits 0. ``bypassPermissions``, the adapter's default,
102
+ asked for nothing in that run (a write under ``.git/`` and ``.claude/``
103
+ included). ``AskUserQuestion`` is not offered to the model under ``-p``; a
104
+ call to it anyway gets an error ``tool_result``. Each of these is kept in
105
+ :attr:`ClaudeStream.refused`, once per ``tool_use_id``: its kind and, for a
106
+ permission, the tool's name and its one allow-listed input
107
+ (:data:`TOOL_DETAILS`), never a command line.
108
+ """
109
+
110
+ from __future__ import annotations
111
+
112
+ import json
113
+ import math
114
+ from decimal import ROUND_HALF_UP, Decimal
115
+
116
+ from .interaction import RequestKind
117
+ from .loop_detect import LoopObserver, action_fingerprint, digest
118
+ from .progress import ProgressEvent, ProgressKind, ProgressSink, clean, relative_path
119
+
120
+ # The one tool input shown per tool. A tool not listed shows its name only.
121
+ # Bash shows its ``description``, never its ``command``: a command line is
122
+ # where credentials are typed, and redaction is a baseline, not a proof.
123
+ TOOL_DETAILS: dict[str, str] = {
124
+ "Read": "file_path",
125
+ "Edit": "file_path",
126
+ "MultiEdit": "file_path",
127
+ "Write": "file_path",
128
+ "NotebookEdit": "notebook_path",
129
+ "Grep": "pattern",
130
+ "Glob": "pattern",
131
+ "Bash": "description",
132
+ "Task": "description",
133
+ "Agent": "description",
134
+ }
135
+ _PATH_INPUTS = frozenset({"file_path", "notebook_path"})
136
+ # The tool through which the model asks the user a question.
137
+ QUESTION_TOOL = "AskUserQuestion"
138
+ # Record types that are activity only. Any other type is counted as unknown
139
+ # (forward compatibility) and is activity too.
140
+ _ACTIVITY_TYPES = frozenset({"rate_limit_event"})
141
+ # Per-turn counts in a ``result``, summed over the invocation's turns.
142
+ _PER_TURN_COUNTS = ("num_turns", "duration_ms")
143
+ # Tool calls whose result has not arrived yet, kept to name a failed tool
144
+ # and to fingerprint the call once it completes. Past the bound a result is
145
+ # still counted, only not named, and is no action for the loop detector.
146
+ MAX_OPEN_TOOLS = 1024
147
+ # Refused requests kept to name; past it they are still counted.
148
+ MAX_REFUSED = 64
149
+ MAX_REASON_CHARS = 400
150
+ MAX_VALUE_CHARS = 120
151
+ MAX_SUBJECT_CHARS = 160
152
+ _MAX_COUNT = 10**15
153
+ # A run that cost more than this is not a reading to believe.
154
+ _MAX_COST_USD = Decimal(1_000_000)
155
+
156
+
157
+ def _count(value: object) -> int | None:
158
+ if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= _MAX_COUNT:
159
+ return None
160
+ return value
161
+
162
+
163
+ def _micro_usd(value: object) -> int | None:
164
+ """``value`` USD as integer micro-USD, or None when it is no finite, non-negative amount."""
165
+ if isinstance(value, bool) or not isinstance(value, int | float):
166
+ return None
167
+ if isinstance(value, float) and not math.isfinite(value):
168
+ return None
169
+ amount = Decimal(repr(value)) if isinstance(value, float) else Decimal(value)
170
+ if not Decimal(0) <= amount <= _MAX_COST_USD:
171
+ return None
172
+ return int((amount * 1_000_000).to_integral_value(rounding=ROUND_HALF_UP))
173
+
174
+
175
+ class ClaudeStream:
176
+ """One stream-json invocation as a reducer. Not thread-safe; one driver owns it.
177
+
178
+ When the stream is over, :attr:`text` holds the final assistant text on
179
+ success and :attr:`failure` the bounded, redacted reason otherwise, and
180
+ :attr:`refused` the requests the CLI refused, whatever the outcome.
181
+ """
182
+
183
+ def __init__(
184
+ self,
185
+ *,
186
+ cwd: str = "",
187
+ emit: ProgressSink | None = None,
188
+ loop: LoopObserver | None = None,
189
+ ) -> None:
190
+ self._cwd = cwd
191
+ self._emit = emit
192
+ self._loop = loop
193
+ self.text: str | None = None
194
+ self.failure: str | None = None
195
+ # stdout ended inside a turn; the driver names the exit code.
196
+ self.exited_early = False
197
+ # Turns opened so far: the first with the stream, each later one by
198
+ # its ``system/init`` or, with every turn so far closed, by a record
199
+ # that opens one. Result n closes turn n, so a turn waits for its
200
+ # result while ``_results < _turns``.
201
+ self._turns = 1
202
+ # The newest turn has had its ``system/init``; the next one opens a turn.
203
+ self._turn_has_init = False
204
+ self._results = 0
205
+ # The per-turn counts summed over the results so far. A count one
206
+ # result lacks makes its sum unknown, and it is dropped for good.
207
+ self._per_turn: dict[str, int] = dict.fromkeys(_PER_TURN_COUNTS, 0)
208
+ # Open tool calls: their name, with a loop observer their input's
209
+ # digest, and their allow-listed input (:data:`TOOL_DETAILS`).
210
+ self._open_tools: dict[str, tuple[str, bytes, str]] = {}
211
+ # Requests the CLI refused (module docstring): the first
212
+ # :data:`MAX_REFUSED`, each its kind and its cleaned subject ("" for
213
+ # none), and how many there were; a ``tool_use_id`` counts once.
214
+ self.refused: list[tuple[RequestKind, str]] = []
215
+ self.refused_count = 0
216
+ self._refused_ids: set[str] = set()
217
+ self._records = 0
218
+ self._oversize = 0
219
+ self._unknown = 0
220
+ self._after_result = 0
221
+ self._tool_calls = 0
222
+ self._tool_errors = 0
223
+ self._retries = 0
224
+ self._result: dict[str, str | int | bool] = {}
225
+
226
+ # -- driver interface ----------------------------------------------------
227
+ def feed(self, data: bytes) -> None:
228
+ """Reduce one stdout line (without its LF)."""
229
+ self._records += 1
230
+ if self.failure is not None:
231
+ return
232
+ try:
233
+ record = json.loads(data.decode("utf-8"))
234
+ except (UnicodeDecodeError, json.JSONDecodeError):
235
+ self._protocol("a stdout line is not JSON")
236
+ return
237
+ except (ValueError, RecursionError):
238
+ # The decoder's own limits, which are not JSONDecodeError: an
239
+ # integer past ``sys.get_int_max_str_digits()`` and nesting past
240
+ # the recursion limit or the C stack, both well inside the
241
+ # per-line bound. An interrupt is neither and still propagates.
242
+ self._protocol("a stdout line exceeds the JSON decoder's integer or nesting limit")
243
+ return
244
+ if not isinstance(record, dict) or not isinstance(record.get("type"), str):
245
+ self._protocol("a stdout line is not a JSON object with a 'type'")
246
+ return
247
+ kind = record["type"]
248
+ if kind == "result":
249
+ if self._results < self._turns:
250
+ self._take_result(record)
251
+ else:
252
+ self._protocol("a result event outside a turn")
253
+ return
254
+ is_init = kind == "system" and record.get("subtype") == "init"
255
+ if self._results == self._turns:
256
+ if kind not in ("assistant", "user") and not is_init:
257
+ self._after_result += 1
258
+ return
259
+ self._turns += 1
260
+ self._turn_has_init = is_init
261
+ elif is_init:
262
+ if self._turn_has_init:
263
+ self._turns += 1
264
+ self._turn_has_init = True
265
+ self._observe(kind, record)
266
+
267
+ def oversize(self, limit: int) -> None:
268
+ """A line past ``limit`` bytes was skipped by the framer."""
269
+ self._oversize += 1
270
+ self._send(ProgressEvent(ProgressKind.ACTIVITY))
271
+
272
+ def framing_error(self, what: str) -> None:
273
+ self._protocol(what)
274
+
275
+ def stream_ended(self) -> None:
276
+ """stdout reached EOF."""
277
+ if self._results == self._turns or self.failure is not None:
278
+ return
279
+ self.exited_early = True
280
+ if self._results:
281
+ self._fail("claude: exited inside a turn that has no result event")
282
+ return
283
+ reason = "claude: exited without a result event"
284
+ if self._oversize:
285
+ reason += (
286
+ f" ({self._oversize} line(s) past the per-line bound were skipped; "
287
+ "the result must arrive whole in one line)"
288
+ )
289
+ self._fail(reason)
290
+
291
+ def summary(self) -> dict[str, str | int | bool]:
292
+ """The flat, bounded, redacted summary for ``execution.json``."""
293
+ return {
294
+ **self._result,
295
+ "results": self._results,
296
+ "records": self._records,
297
+ "oversize_records": self._oversize,
298
+ "unknown_events": self._unknown,
299
+ "records_after_result": self._after_result,
300
+ "tool_calls": self._tool_calls,
301
+ "tool_errors": self._tool_errors,
302
+ "api_retries": self._retries,
303
+ "failure": self.failure or "",
304
+ }
305
+
306
+ # -- internals -----------------------------------------------------------
307
+ def _send(self, event: ProgressEvent) -> None:
308
+ if self._emit is not None:
309
+ self._emit(event)
310
+
311
+ def _fail(self, reason: str) -> None:
312
+ if self.failure is None:
313
+ self.failure = clean(reason, MAX_REASON_CHARS)
314
+ self.text = None
315
+
316
+ def _protocol(self, what: str) -> None:
317
+ self._fail(f"claude: stream-json protocol violation: {what}")
318
+
319
+ def _take_result(self, record: dict) -> None:
320
+ self._results += 1
321
+ subtype = record.get("subtype")
322
+ is_error = record.get("is_error")
323
+ summary: dict[str, str | int | bool] = {
324
+ "result_subtype": clean(subtype, MAX_VALUE_CHARS),
325
+ "is_error": is_error is True,
326
+ "terminal_reason": clean(record.get("terminal_reason"), MAX_VALUE_CHARS),
327
+ "stop_reason": clean(record.get("stop_reason"), MAX_VALUE_CHARS),
328
+ }
329
+ for key, total in list(self._per_turn.items()):
330
+ value = _count(record.get(key))
331
+ if value is None:
332
+ del self._per_turn[key]
333
+ else:
334
+ self._per_turn[key] = min(total + value, _MAX_COUNT)
335
+ summary.update(self._per_turn)
336
+ cost = _micro_usd(record.get("total_cost_usd"))
337
+ if cost is not None:
338
+ summary["cost_micro_usd"] = cost
339
+ denials = record.get("permission_denials")
340
+ if isinstance(denials, list):
341
+ summary["permission_denials"] = len(denials)
342
+ for denial in denials:
343
+ if isinstance(denial, dict):
344
+ name = denial.get("tool_name")
345
+ name = name if isinstance(name, str) else ""
346
+ detail = self._detail(name, denial.get("tool_input"))
347
+ self._refuse(RequestKind.PERMISSION, denial.get("tool_use_id"), name, detail)
348
+ self._result = summary
349
+ result = record.get("result")
350
+ if is_error is True:
351
+ facts = [f"subtype {summary['result_subtype'] or '-'}"]
352
+ if summary["terminal_reason"]:
353
+ facts.append(f"terminal_reason {summary['terminal_reason']}")
354
+ status = _count(record.get("api_error_status"))
355
+ if status is not None:
356
+ facts.append(f"api_error_status {status}")
357
+ excerpt = clean(result, MAX_VALUE_CHARS * 2)
358
+ self._fail(
359
+ f"claude: the run ended in an error ({', '.join(facts)})"
360
+ + (f": {excerpt}" if excerpt else "")
361
+ )
362
+ elif is_error is not False:
363
+ self._protocol("the result event has no boolean 'is_error'")
364
+ elif subtype != "success":
365
+ self._fail(f"claude: the run did not succeed (subtype {clean(subtype, 60) or '-'})")
366
+ elif not isinstance(result, str):
367
+ self._protocol("the result event has no string 'result'")
368
+ else:
369
+ self.text = result
370
+
371
+ def _observe(self, kind: str, record: dict) -> None:
372
+ if kind == "system":
373
+ self._system(record)
374
+ elif kind == "assistant":
375
+ self._assistant(record)
376
+ elif kind == "user":
377
+ self._user(record)
378
+ else:
379
+ if kind not in _ACTIVITY_TYPES:
380
+ self._unknown += 1
381
+ self._send(ProgressEvent(ProgressKind.ACTIVITY))
382
+
383
+ def _system(self, record: dict) -> None:
384
+ subtype = record.get("subtype")
385
+ if subtype == "init":
386
+ model = record.get("model")
387
+ self._send(
388
+ ProgressEvent(ProgressKind.STARTED, detail=model if isinstance(model, str) else "")
389
+ )
390
+ elif subtype == "thinking_tokens":
391
+ tokens = _count(record.get("estimated_tokens")) or 0
392
+ self._send(ProgressEvent(ProgressKind.THINKING, tokens=tokens))
393
+ elif subtype == "api_retry":
394
+ self._retries += 1
395
+ if self._loop is not None:
396
+ self._loop.retry()
397
+ attempt, limit = _count(record.get("attempt")), _count(record.get("max_retries"))
398
+ detail = ""
399
+ if attempt is not None:
400
+ detail = f"attempt {attempt}" + (f" of {limit}" if limit is not None else "")
401
+ self._send(ProgressEvent(ProgressKind.PROVIDER_RETRY, detail=detail))
402
+ elif subtype == "permission_denied":
403
+ tool_id = record.get("tool_use_id")
404
+ call = self._open_tools.get(tool_id) if isinstance(tool_id, str) else None
405
+ name = record.get("tool_name")
406
+ if not isinstance(name, str):
407
+ name = call[0] if call is not None else ""
408
+ self._refuse(RequestKind.PERMISSION, tool_id, name, call[2] if call else "")
409
+ self._send(ProgressEvent(ProgressKind.ACTIVITY))
410
+ else:
411
+ # Hooks, status and compaction notices: liveness only. A hook's
412
+ # output is the operator's, and is never shown.
413
+ self._send(ProgressEvent(ProgressKind.ACTIVITY))
414
+
415
+ @staticmethod
416
+ def _blocks(record: dict) -> list[dict]:
417
+ message = record.get("message")
418
+ content = message.get("content") if isinstance(message, dict) else None
419
+ if not isinstance(content, list):
420
+ return []
421
+ return [block for block in content if isinstance(block, dict)]
422
+
423
+ def _assistant(self, record: dict) -> None:
424
+ sent = False
425
+ for block in self._blocks(record):
426
+ kind = block.get("type")
427
+ if kind == "thinking":
428
+ self._send(ProgressEvent(ProgressKind.THINKING))
429
+ elif kind == "text":
430
+ self._send(ProgressEvent(ProgressKind.ASSISTANT_TEXT))
431
+ elif kind == "tool_use":
432
+ self._tool_use(block)
433
+ else:
434
+ continue
435
+ sent = True
436
+ if not sent:
437
+ self._send(ProgressEvent(ProgressKind.ACTIVITY))
438
+ if self._loop is not None:
439
+ self._loop.turn()
440
+
441
+ def _tool_use(self, block: dict) -> None:
442
+ self._tool_calls += 1
443
+ name = block.get("name")
444
+ name = name if isinstance(name, str) else ""
445
+ tool_id = block.get("id")
446
+ tool_input = block.get("input")
447
+ detail = self._detail(name, tool_input)
448
+ if isinstance(tool_id, str) and len(self._open_tools) < MAX_OPEN_TOOLS:
449
+ self._open_tools[tool_id] = (
450
+ name,
451
+ digest(tool_input) if self._loop is not None else b"",
452
+ detail,
453
+ )
454
+ self._send(ProgressEvent(ProgressKind.TOOL_STARTED, tool=name, detail=detail))
455
+
456
+ def _detail(self, name: str, tool_input: object) -> str:
457
+ """The one input of ``name``'s call shown for it (:data:`TOOL_DETAILS`), or ""."""
458
+ field = TOOL_DETAILS.get(name)
459
+ if field is None or not isinstance(tool_input, dict):
460
+ return ""
461
+ value = tool_input.get(field)
462
+ if field in _PATH_INPUTS:
463
+ return relative_path(value, self._cwd)
464
+ return value if isinstance(value, str) else ""
465
+
466
+ def _refuse(self, kind: RequestKind, tool_id: object, name: str, detail: str) -> None:
467
+ """Keep a request the CLI refused, once per ``tool_use_id``."""
468
+ if isinstance(tool_id, str):
469
+ if tool_id in self._refused_ids:
470
+ return
471
+ if len(self._refused_ids) < MAX_OPEN_TOOLS:
472
+ self._refused_ids.add(tool_id)
473
+ self.refused_count += 1
474
+ if len(self.refused) < MAX_REFUSED:
475
+ subject = f"{name}: {detail}" if name and detail else name
476
+ self.refused.append((kind, clean(subject, MAX_SUBJECT_CHARS)))
477
+
478
+ def _user(self, record: dict) -> None:
479
+ sent = False
480
+ for block in self._blocks(record):
481
+ if block.get("type") != "tool_result":
482
+ continue
483
+ tool_id = block.get("tool_use_id")
484
+ call = self._open_tools.pop(tool_id, None) if isinstance(tool_id, str) else None
485
+ name = call[0] if call is not None else ""
486
+ if block.get("is_error") is True:
487
+ self._tool_errors += 1
488
+ if name == QUESTION_TOOL:
489
+ # Not offered under -p (module docstring): the question
490
+ # reached no one. Its text is the model's; only the kind is kept.
491
+ self._refuse(RequestKind.QUESTION, tool_id, "", "")
492
+ self._send(ProgressEvent(ProgressKind.TOOL_FAILED, tool=name))
493
+ else:
494
+ self._send(ProgressEvent(ProgressKind.TOOL_FINISHED, tool=name))
495
+ if self._loop is not None and call is not None:
496
+ # The id differs on every call; everything else is the result.
497
+ result = {key: value for key, value in block.items() if key != "tool_use_id"}
498
+ self._loop.action(name, action_fingerprint(name, call[1], digest(result)))
499
+ sent = True
500
+ if not sent:
501
+ self._send(ProgressEvent(ProgressKind.ACTIVITY))