krons 0.1.1__py3-none-any.whl → 0.2.1__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 (142) hide show
  1. krons/__init__.py +49 -0
  2. krons/agent/__init__.py +144 -0
  3. krons/agent/mcps/__init__.py +14 -0
  4. krons/agent/mcps/loader.py +287 -0
  5. krons/agent/mcps/wrapper.py +799 -0
  6. krons/agent/message/__init__.py +20 -0
  7. krons/agent/message/action.py +69 -0
  8. krons/agent/message/assistant.py +52 -0
  9. krons/agent/message/common.py +49 -0
  10. krons/agent/message/instruction.py +130 -0
  11. krons/agent/message/prepare_msg.py +187 -0
  12. krons/agent/message/role.py +53 -0
  13. krons/agent/message/system.py +53 -0
  14. krons/agent/operations/__init__.py +82 -0
  15. krons/agent/operations/act.py +100 -0
  16. krons/agent/operations/generate.py +145 -0
  17. krons/agent/operations/llm_reparse.py +89 -0
  18. krons/agent/operations/operate.py +247 -0
  19. krons/agent/operations/parse.py +243 -0
  20. krons/agent/operations/react.py +286 -0
  21. krons/agent/operations/specs.py +235 -0
  22. krons/agent/operations/structure.py +151 -0
  23. krons/agent/operations/utils.py +79 -0
  24. krons/agent/providers/__init__.py +17 -0
  25. krons/agent/providers/anthropic_messages.py +146 -0
  26. krons/agent/providers/claude_code.py +276 -0
  27. krons/agent/providers/gemini.py +268 -0
  28. krons/agent/providers/match.py +75 -0
  29. krons/agent/providers/oai_chat.py +174 -0
  30. krons/agent/third_party/__init__.py +2 -0
  31. krons/agent/third_party/anthropic_models.py +154 -0
  32. krons/agent/third_party/claude_code.py +682 -0
  33. krons/agent/third_party/gemini_models.py +508 -0
  34. krons/agent/third_party/openai_models.py +295 -0
  35. krons/agent/tool.py +291 -0
  36. krons/core/__init__.py +56 -74
  37. krons/core/base/__init__.py +121 -0
  38. krons/core/{broadcaster.py → base/broadcaster.py} +7 -3
  39. krons/core/{element.py → base/element.py} +13 -5
  40. krons/core/{event.py → base/event.py} +39 -6
  41. krons/core/{eventbus.py → base/eventbus.py} +3 -1
  42. krons/core/{flow.py → base/flow.py} +11 -4
  43. krons/core/{graph.py → base/graph.py} +24 -8
  44. krons/core/{node.py → base/node.py} +44 -19
  45. krons/core/{pile.py → base/pile.py} +22 -8
  46. krons/core/{processor.py → base/processor.py} +21 -7
  47. krons/core/{progression.py → base/progression.py} +3 -1
  48. krons/{specs → core/specs}/__init__.py +0 -5
  49. krons/{specs → core/specs}/adapters/dataclass_field.py +16 -8
  50. krons/{specs → core/specs}/adapters/pydantic_adapter.py +11 -5
  51. krons/{specs → core/specs}/adapters/sql_ddl.py +14 -8
  52. krons/{specs → core/specs}/catalog/__init__.py +2 -2
  53. krons/{specs → core/specs}/catalog/_audit.py +2 -2
  54. krons/{specs → core/specs}/catalog/_common.py +2 -2
  55. krons/{specs → core/specs}/catalog/_content.py +4 -4
  56. krons/{specs → core/specs}/catalog/_enforcement.py +3 -3
  57. krons/{specs → core/specs}/factory.py +5 -5
  58. krons/{specs → core/specs}/operable.py +8 -2
  59. krons/{specs → core/specs}/protocol.py +4 -2
  60. krons/{specs → core/specs}/spec.py +23 -11
  61. krons/{types → core/types}/base.py +4 -2
  62. krons/{types → core/types}/db_types.py +2 -2
  63. krons/errors.py +13 -13
  64. krons/protocols.py +9 -4
  65. krons/resource/__init__.py +89 -0
  66. krons/{services → resource}/backend.py +48 -22
  67. krons/{services → resource}/endpoint.py +28 -14
  68. krons/{services → resource}/hook.py +20 -7
  69. krons/{services → resource}/imodel.py +46 -28
  70. krons/{services → resource}/registry.py +26 -24
  71. krons/{services → resource}/utilities/rate_limited_executor.py +7 -3
  72. krons/{services → resource}/utilities/rate_limiter.py +3 -1
  73. krons/{services → resource}/utilities/resilience.py +15 -5
  74. krons/resource/utilities/token_calculator.py +185 -0
  75. krons/session/__init__.py +12 -17
  76. krons/session/constraints.py +70 -0
  77. krons/session/exchange.py +11 -3
  78. krons/session/message.py +3 -1
  79. krons/session/registry.py +35 -0
  80. krons/session/session.py +165 -174
  81. krons/utils/__init__.py +45 -0
  82. krons/utils/_function_arg_parser.py +99 -0
  83. krons/utils/_pythonic_function_call.py +249 -0
  84. krons/utils/_to_list.py +9 -3
  85. krons/utils/_utils.py +6 -2
  86. krons/utils/concurrency/_async_call.py +4 -2
  87. krons/utils/concurrency/_errors.py +3 -1
  88. krons/utils/concurrency/_patterns.py +3 -1
  89. krons/utils/concurrency/_resource_tracker.py +6 -2
  90. krons/utils/display.py +257 -0
  91. krons/utils/fuzzy/__init__.py +6 -1
  92. krons/utils/fuzzy/_fuzzy_match.py +14 -8
  93. krons/utils/fuzzy/_string_similarity.py +3 -1
  94. krons/utils/fuzzy/_to_dict.py +3 -1
  95. krons/utils/schemas/__init__.py +26 -0
  96. krons/utils/schemas/_breakdown_pydantic_annotation.py +131 -0
  97. krons/utils/schemas/_formatter.py +72 -0
  98. krons/utils/schemas/_minimal_yaml.py +151 -0
  99. krons/utils/schemas/_typescript.py +153 -0
  100. krons/utils/validators/__init__.py +3 -0
  101. krons/utils/validators/_validate_image_url.py +56 -0
  102. krons/work/__init__.py +115 -0
  103. krons/work/engine.py +333 -0
  104. krons/work/form.py +242 -0
  105. krons/{operations → work/operations}/__init__.py +7 -4
  106. krons/{operations → work/operations}/builder.py +1 -1
  107. krons/{enforcement → work/operations}/context.py +36 -5
  108. krons/{operations → work/operations}/flow.py +13 -5
  109. krons/{operations → work/operations}/node.py +45 -43
  110. krons/work/operations/registry.py +103 -0
  111. krons/work/report.py +268 -0
  112. krons/work/rules/__init__.py +47 -0
  113. krons/{enforcement → work/rules}/common/boolean.py +3 -1
  114. krons/{enforcement → work/rules}/common/choice.py +9 -3
  115. krons/{enforcement → work/rules}/common/number.py +3 -1
  116. krons/{enforcement → work/rules}/common/string.py +9 -3
  117. krons/{enforcement → work/rules}/rule.py +1 -1
  118. krons/{enforcement → work/rules}/validator.py +20 -5
  119. krons/work/worker.py +266 -0
  120. {krons-0.1.1.dist-info → krons-0.2.1.dist-info}/METADATA +15 -1
  121. krons-0.2.1.dist-info/RECORD +151 -0
  122. krons/enforcement/__init__.py +0 -57
  123. krons/enforcement/policy.py +0 -80
  124. krons/enforcement/service.py +0 -370
  125. krons/operations/registry.py +0 -92
  126. krons/services/__init__.py +0 -81
  127. krons/specs/phrase.py +0 -405
  128. krons-0.1.1.dist-info/RECORD +0 -101
  129. /krons/{specs → core/specs}/adapters/__init__.py +0 -0
  130. /krons/{specs → core/specs}/adapters/_utils.py +0 -0
  131. /krons/{specs → core/specs}/adapters/factory.py +0 -0
  132. /krons/{types → core/types}/__init__.py +0 -0
  133. /krons/{types → core/types}/_sentinel.py +0 -0
  134. /krons/{types → core/types}/identity.py +0 -0
  135. /krons/{services → resource}/utilities/__init__.py +0 -0
  136. /krons/{services → resource}/utilities/header_factory.py +0 -0
  137. /krons/{enforcement → work/rules}/common/__init__.py +0 -0
  138. /krons/{enforcement → work/rules}/common/mapping.py +0 -0
  139. /krons/{enforcement → work/rules}/common/model.py +0 -0
  140. /krons/{enforcement → work/rules}/registry.py +0 -0
  141. {krons-0.1.1.dist-info → krons-0.2.1.dist-info}/WHEEL +0 -0
  142. {krons-0.1.1.dist-info → krons-0.2.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,682 @@
1
+ # Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ from __future__ import annotations
5
+
6
+ import asyncio # Required for subprocess management (create_subprocess_exec)
7
+ import codecs
8
+ import contextlib
9
+ import inspect
10
+ import json # Required for JSONDecoder.raw_decode() (streaming JSON parsing)
11
+ import logging
12
+ import shutil
13
+ from collections.abc import AsyncIterator, Callable
14
+ from dataclasses import dataclass
15
+ from dataclasses import field as datafield
16
+ from pathlib import Path
17
+ from textwrap import shorten
18
+ from typing import Any, Literal
19
+
20
+ from pydantic import BaseModel, Field, field_validator, model_validator
21
+
22
+ from krons.utils import json_dump, now_utc
23
+
24
+ HAS_CLAUDE_CODE_CLI = False
25
+ CLAUDE_CLI = None
26
+
27
+ if (c := (shutil.which("claude") or "claude")) and shutil.which(c):
28
+ HAS_CLAUDE_CODE_CLI = True
29
+ CLAUDE_CLI = c
30
+
31
+ logging.basicConfig(level=logging.INFO)
32
+ log = logging.getLogger("claude-cli")
33
+
34
+ # --------------------------------------------------------------------------- constants
35
+ ClaudePermission = Literal[
36
+ "default",
37
+ "acceptEdits",
38
+ "bypassPermissions",
39
+ "dangerously-skip-permissions",
40
+ ]
41
+
42
+ CLAUDE_CODE_OPTION_PARAMS = {
43
+ "allowed_tools",
44
+ "max_thinking_tokens",
45
+ "mcp_tools",
46
+ "mcp_servers",
47
+ "permission_mode",
48
+ "continue_conversation",
49
+ "resume",
50
+ "max_turns",
51
+ "disallowed_tools",
52
+ "model",
53
+ "permission_prompt_tool_name",
54
+ "cwd",
55
+ "system_prompt",
56
+ "append_system_prompt",
57
+ }
58
+
59
+
60
+ __all__ = (
61
+ "ClaudeChunk",
62
+ "ClaudeCodeRequest",
63
+ "ClaudeSession",
64
+ "stream_claude_code_cli",
65
+ )
66
+
67
+
68
+ # --------------------------------------------------------------------------- request model
69
+ class ClaudeCodeRequest(BaseModel):
70
+ # -- conversational bits -------------------------------------------------
71
+ prompt: str = Field(description="The prompt for Claude Code")
72
+ system_prompt: str | None = None
73
+ append_system_prompt: str | None = None
74
+ max_turns: int | None = None
75
+ continue_conversation: bool = False
76
+ resume: str | None = None
77
+
78
+ # -- repo / workspace ----------------------------------------------------
79
+ repo: Path = Field(default_factory=Path.cwd, exclude=True)
80
+ ws: str | None = None # sub-directory under repo
81
+ add_dir: str | None = None # extra read-only mount
82
+ allowed_tools: list[str] | None = None
83
+
84
+ # -- runtime & safety ----------------------------------------------------
85
+ model: Literal["sonnet", "opus"] | str | None = "sonnet"
86
+ max_thinking_tokens: int | None = None
87
+ mcp_tools: list[str] = Field(default_factory=list)
88
+ mcp_servers: dict[str, Any] = Field(default_factory=dict)
89
+ mcp_config: str | Path | None = Field(None, exclude=True)
90
+ permission_mode: ClaudePermission | None = None
91
+ permission_prompt_tool_name: str | None = None
92
+ disallowed_tools: list[str] = Field(default_factory=list)
93
+
94
+ # -- internal use --------------------------------------------------------
95
+ auto_finish: bool = Field(
96
+ default=False,
97
+ description="Automatically finish the conversation after the first response",
98
+ )
99
+ verbose: bool = Field(default=False)
100
+ cli_display_theme: Literal["light", "dark"] = "dark"
101
+ cli_include_summary: bool = Field(default=False)
102
+
103
+ # ------------------------ validators & helpers --------------------------
104
+ @field_validator("permission_mode", mode="before")
105
+ @classmethod
106
+ def _norm_perm(cls, v):
107
+ if v in {
108
+ "dangerously-skip-permissions",
109
+ "--dangerously-skip-permissions",
110
+ }:
111
+ return "bypassPermissions"
112
+ return v
113
+
114
+ @model_validator(mode="before")
115
+ @classmethod
116
+ def _validate_message_prompt(cls, data):
117
+ if data.get("prompt"):
118
+ return data
119
+
120
+ if not (msg := data.get("messages")):
121
+ raise ValueError("messages may not be empty")
122
+ resume = data.get("resume")
123
+ continue_conversation = data.get("continue_conversation")
124
+
125
+ prompt = ""
126
+
127
+ # 1. if resume or continue_conversation, use the last message
128
+ if resume or continue_conversation:
129
+ continue_conversation = True
130
+ prompt = msg[-1]["content"]
131
+ if isinstance(prompt, (dict, list)):
132
+ prompt = json_dump(prompt)
133
+
134
+ # 2. else, use entire messages except system message
135
+ else:
136
+ prompts = []
137
+ continue_conversation = False
138
+ for message in msg:
139
+ if message["role"] != "system":
140
+ content = message["content"]
141
+ prompts.append(
142
+ json_dump(content)
143
+ if isinstance(content, (dict, list))
144
+ else content
145
+ )
146
+
147
+ prompt = "\n".join(prompts)
148
+
149
+ # 3. assemble the request data
150
+ data_: dict[str, Any] = dict(
151
+ prompt=prompt,
152
+ resume=resume,
153
+ continue_conversation=bool(continue_conversation),
154
+ )
155
+
156
+ # 4. extract system prompt if available
157
+ if (msg[0]["role"] == "system") and (resume or continue_conversation):
158
+ data_["system_prompt"] = msg[0]["content"]
159
+
160
+ if data.get("append_system_prompt"):
161
+ data_["append_system_prompt"] = str(data.get("append_system_prompt"))
162
+
163
+ data_.update(data)
164
+ return data_
165
+
166
+ # Workspace path derived from repo + ws
167
+ def cwd(self) -> Path:
168
+ if not self.ws:
169
+ return self.repo
170
+
171
+ # Convert to Path object for proper validation
172
+ ws_path = Path(self.ws)
173
+
174
+ # Check for absolute paths or directory traversal attempts
175
+ if ws_path.is_absolute():
176
+ raise ValueError(
177
+ f"Workspace path must be relative, got absolute: {self.ws}"
178
+ )
179
+
180
+ if ".." in ws_path.parts:
181
+ raise ValueError(
182
+ f"Directory traversal detected in workspace path: {self.ws}"
183
+ )
184
+
185
+ # Resolve paths to handle symlinks and normalize
186
+ repo_resolved = self.repo.resolve()
187
+ result = (self.repo / ws_path).resolve()
188
+
189
+ # Ensure the resolved path is within the repository bounds
190
+ try:
191
+ result.relative_to(repo_resolved)
192
+ except ValueError:
193
+ raise ValueError(
194
+ f"Workspace path escapes repository bounds. "
195
+ f"Repository: {repo_resolved}, Workspace: {result}"
196
+ )
197
+
198
+ return result
199
+
200
+ @model_validator(mode="after")
201
+ def _check_perm_workspace(self):
202
+ if self.permission_mode == "bypassPermissions":
203
+ # Use secure path validation with resolved paths
204
+ repo_resolved = self.repo.resolve()
205
+ cwd_resolved = self.cwd().resolve()
206
+
207
+ # Check if cwd is within repo bounds using proper path methods
208
+ try:
209
+ cwd_resolved.relative_to(repo_resolved)
210
+ except ValueError:
211
+ raise ValueError(
212
+ f"With bypassPermissions, workspace must be within repository bounds. "
213
+ f"Repository: {repo_resolved}, Workspace: {cwd_resolved}"
214
+ )
215
+ return self
216
+
217
+ # ------------------------ CLI helpers -----------------------------------
218
+ def as_cmd_args(self) -> list[str]:
219
+ """Build argument list for the *Node* `claude` CLI."""
220
+ args: list[str] = ["-p", self.prompt, "--output-format", "stream-json"]
221
+ if self.allowed_tools:
222
+ args.append("--allowedTools")
223
+ for tool in self.allowed_tools:
224
+ args.append(f'"{tool}"')
225
+
226
+ if self.disallowed_tools:
227
+ args.append("--disallowedTools")
228
+ for tool in self.disallowed_tools:
229
+ args.append(f'"{tool}"')
230
+
231
+ if self.resume:
232
+ args += ["--resume", self.resume]
233
+ elif self.continue_conversation:
234
+ args.append("--continue")
235
+
236
+ if self.max_turns:
237
+ # +1 because CLI counts *pairs*
238
+ args += ["--max-turns", str(self.max_turns + 1)]
239
+
240
+ if self.permission_mode == "bypassPermissions":
241
+ args += ["--dangerously-skip-permissions"]
242
+
243
+ if self.add_dir:
244
+ args += ["--add-dir", self.add_dir]
245
+
246
+ if self.permission_prompt_tool_name:
247
+ args += [
248
+ "--permission-prompt-tool",
249
+ self.permission_prompt_tool_name,
250
+ ]
251
+
252
+ if self.mcp_config:
253
+ args += ["--mcp-config", f'"{self.mcp_config}"']
254
+
255
+ args += ["--model", self.model or "sonnet", "--verbose"]
256
+ return args
257
+
258
+
259
+ @dataclass
260
+ class ClaudeChunk:
261
+ """Low-level wrapper around every NDJSON object coming from the CLI."""
262
+
263
+ raw: dict[str, Any]
264
+ type: str
265
+ # convenience views
266
+ thinking: str | None = None
267
+ text: str | None = None
268
+ tool_use: dict[str, Any] | None = None
269
+ tool_result: dict[str, Any] | None = None
270
+
271
+
272
+ @dataclass
273
+ class ClaudeSession:
274
+ """Aggregated view of a whole CLI conversation."""
275
+
276
+ session_id: str | None = None
277
+ model: str | None = None
278
+
279
+ # chronological log
280
+ chunks: list[ClaudeChunk] = datafield(default_factory=list)
281
+
282
+ # materialised views
283
+ thinking_log: list[str] = datafield(default_factory=list)
284
+ messages: list[dict[str, Any]] = datafield(default_factory=list)
285
+ tool_uses: list[dict[str, Any]] = datafield(default_factory=list)
286
+ tool_results: list[dict[str, Any]] = datafield(default_factory=list)
287
+
288
+ # final summary
289
+ result: str = ""
290
+ usage: dict[str, Any] = datafield(default_factory=dict)
291
+ total_cost_usd: float | None = None
292
+ num_turns: int | None = None
293
+ duration_ms: int | None = None
294
+ duration_api_ms: int | None = None
295
+ is_error: bool = False
296
+ summary: dict | None = None
297
+
298
+ def populate_summary(self) -> None:
299
+ self.summary = _extract_summary(self)
300
+
301
+
302
+ def _extract_summary(session: ClaudeSession) -> dict[str, Any]:
303
+ tool_counts: dict[str, int] = {}
304
+ tool_details: list[dict[str, Any]] = []
305
+ file_operations: dict[str, list[str]] = {"reads": [], "writes": [], "edits": []}
306
+ key_actions: list[str] = []
307
+
308
+ # Process tool uses from the clean materialized view
309
+ for tool_use in session.tool_uses:
310
+ tool_name = tool_use.get("name", "unknown")
311
+ tool_input = tool_use.get("input", {})
312
+ tool_id = tool_use.get("id", "")
313
+
314
+ # Count tool usage
315
+ tool_counts[tool_name] = tool_counts.get(tool_name, 0) + 1
316
+
317
+ # Store detailed info
318
+ tool_details.append({"tool": tool_name, "id": tool_id, "input": tool_input})
319
+
320
+ # Categorize file operations and actions
321
+ if tool_name in ["Read", "read"]:
322
+ file_path = tool_input.get("file_path", "unknown")
323
+ file_operations["reads"].append(file_path)
324
+ key_actions.append(f"Read {file_path}")
325
+
326
+ elif tool_name in ["Write", "write"]:
327
+ file_path = tool_input.get("file_path", "unknown")
328
+ file_operations["writes"].append(file_path)
329
+ key_actions.append(f"Wrote {file_path}")
330
+
331
+ elif tool_name in ["Edit", "edit", "MultiEdit"]:
332
+ file_path = tool_input.get("file_path", "unknown")
333
+ file_operations["edits"].append(file_path)
334
+ key_actions.append(f"Edited {file_path}")
335
+
336
+ elif tool_name in ["Bash", "bash"]:
337
+ command = tool_input.get("command", "")
338
+ command_summary = command[:50] + "..." if len(command) > 50 else command
339
+ key_actions.append(f"Ran: {command_summary}")
340
+
341
+ elif tool_name in ["Glob", "glob"]:
342
+ pattern = tool_input.get("pattern", "")
343
+ key_actions.append(f"Searched files: {pattern}")
344
+
345
+ elif tool_name in ["Grep", "grep"]:
346
+ pattern = tool_input.get("pattern", "")
347
+ key_actions.append(f"Searched content: {pattern}")
348
+
349
+ elif tool_name in ["Task", "task"]:
350
+ description = tool_input.get("description", "")
351
+ key_actions.append(f"Spawned task: {description}")
352
+
353
+ elif tool_name.startswith("mcp__"):
354
+ # MCP tool usage - extract the operation type
355
+ operation = tool_name.replace("mcp__", "")
356
+ key_actions.append(f"MCP {operation}")
357
+
358
+ elif tool_name == "TodoWrite":
359
+ todos = tool_input.get("todos", [])
360
+ key_actions.append(f"Created {len(todos)} todos")
361
+
362
+ else:
363
+ key_actions.append(f"Used {tool_name}")
364
+
365
+ # Deduplicate key actions
366
+ key_actions = (
367
+ list(dict.fromkeys(key_actions))
368
+ if key_actions
369
+ else ["No specific actions detected"]
370
+ )
371
+
372
+ # Deduplicate file paths
373
+ for op_type in file_operations:
374
+ file_operations[op_type] = list(dict.fromkeys(file_operations[op_type]))
375
+
376
+ # Extract result summary (first 200 chars)
377
+ result_summary = (
378
+ (session.result[:200] + "...") if len(session.result) > 200 else session.result
379
+ )
380
+
381
+ return {
382
+ "tool_counts": tool_counts,
383
+ "tool_details": tool_details,
384
+ "file_operations": file_operations,
385
+ "key_actions": key_actions,
386
+ "total_tool_calls": sum(tool_counts.values()),
387
+ "result_summary": result_summary,
388
+ "usage_stats": {
389
+ "total_cost_usd": session.total_cost_usd,
390
+ "num_turns": session.num_turns,
391
+ "duration_ms": session.duration_ms,
392
+ "duration_api_ms": session.duration_api_ms,
393
+ **session.usage,
394
+ },
395
+ }
396
+
397
+
398
+ async def _ndjson_from_cli(request: ClaudeCodeRequest):
399
+ """
400
+ Yields each JSON object emitted by the *claude-code* CLI.
401
+
402
+ • Robust against UTF-8 splits across chunks (incremental decoder).
403
+ • Robust against braces inside strings (uses json.JSONDecoder.raw_decode)
404
+ • Falls back to fuzzy JSON parsing when necessary.
405
+ """
406
+ from krons.utils.fuzzy import fuzzy_json
407
+
408
+ if CLAUDE_CLI is None:
409
+ raise RuntimeError(
410
+ "Claude CLI not found. Please install @anthropic-ai/claude-code"
411
+ )
412
+
413
+ workspace = request.cwd()
414
+ workspace.mkdir(parents=True, exist_ok=True)
415
+
416
+ proc = await asyncio.create_subprocess_exec(
417
+ CLAUDE_CLI,
418
+ *request.as_cmd_args(),
419
+ cwd=str(workspace),
420
+ stdout=asyncio.subprocess.PIPE,
421
+ stderr=asyncio.subprocess.PIPE,
422
+ )
423
+
424
+ decoder = codecs.getincrementaldecoder("utf-8")()
425
+ json_decoder = json.JSONDecoder()
426
+ buffer: str = "" # text buffer that may hold >1 JSON objects
427
+
428
+ if proc.stdout is None:
429
+ raise RuntimeError("Failed to capture stdout from Claude CLI")
430
+
431
+ try:
432
+ while True:
433
+ chunk = await proc.stdout.read(4096)
434
+ if not chunk:
435
+ break
436
+
437
+ # 1) decode *incrementally* so we never split multibyte chars
438
+ buffer += decoder.decode(chunk)
439
+
440
+ # 2) try to peel off as many complete JSON objs as possible
441
+ while buffer:
442
+ buffer = buffer.lstrip() # remove leading spaces/newlines
443
+ if not buffer:
444
+ break
445
+ try:
446
+ obj, idx = json_decoder.raw_decode(buffer)
447
+ yield obj
448
+ buffer = buffer[idx:] # keep remainder for next round
449
+ except json.JSONDecodeError:
450
+ # incomplete → need more bytes
451
+ break
452
+
453
+ # 3) flush any tail bytes in the incremental decoder
454
+ buffer += decoder.decode(b"", final=True)
455
+ buffer = buffer.strip()
456
+ if buffer:
457
+ try:
458
+ obj, idx = json_decoder.raw_decode(buffer)
459
+ yield obj
460
+ except json.JSONDecodeError:
461
+ try:
462
+ # Use fuzzy JSON parser to handle malformed JSON
463
+ obj = fuzzy_json(buffer)
464
+ yield obj
465
+ log.warning(
466
+ "Repaired malformed JSON fragment at stream end using fuzzy parser"
467
+ )
468
+ except Exception:
469
+ log.error("Skipped unrecoverable JSON tail: %.120s…", buffer)
470
+
471
+ # 4) propagate non-zero exit code
472
+ if await proc.wait() != 0:
473
+ err = ""
474
+ if proc.stderr is not None:
475
+ err = (await proc.stderr.read()).decode().strip()
476
+ raise RuntimeError(err or "CLI exited non-zero")
477
+
478
+ finally:
479
+ with contextlib.suppress(ProcessLookupError):
480
+ proc.terminate()
481
+ await proc.wait()
482
+
483
+
484
+ # --------------------------------------------------------------------------- SSE route
485
+ async def stream_cc_cli_events(request: ClaudeCodeRequest):
486
+ if not CLAUDE_CLI:
487
+ raise RuntimeError(
488
+ "Claude CLI binary not found (npm i -g @anthropic-ai/claude-code)"
489
+ )
490
+ async for obj in _ndjson_from_cli(request):
491
+ yield obj
492
+ yield {"type": "done"}
493
+
494
+
495
+ def _pp_system(sys_obj: dict[str, Any], theme) -> None:
496
+ txt = (
497
+ f"◼️ **Claude Code Session** \n"
498
+ f"- id: `{sys_obj.get('session_id', '?')}` \n"
499
+ f"- model: `{sys_obj.get('model', '?')}` \n"
500
+ f"- tools: {', '.join(sys_obj.get('tools', [])[:8])}"
501
+ + ("…" if len(sys_obj.get("tools", [])) > 8 else "")
502
+ )
503
+ print(txt)
504
+
505
+
506
+ def _pp_thinking(thought: str, theme) -> None:
507
+ text = f"""
508
+ 🧠 Thinking:
509
+ {thought}
510
+ """
511
+ print(text)
512
+
513
+
514
+ def _pp_assistant_text(text: str, theme) -> None:
515
+ txt = f"""
516
+ > 🗣️ Claude:
517
+ {text}
518
+ """
519
+ print(txt)
520
+
521
+
522
+ def _pp_tool_use(tu: dict[str, Any], theme) -> None:
523
+ preview = shorten(str(tu["input"]).replace("\n", " "), 130)
524
+ body = f"- 🔧 Tool Use — {tu['name']}({tu['id']}) - input: {preview}"
525
+ print(body)
526
+
527
+
528
+ def _pp_tool_result(tr: dict[str, Any], theme) -> None:
529
+ body_preview = shorten(str(tr["content"]).replace("\n", " "), 130)
530
+ status = "ERR" if tr.get("is_error") else "OK"
531
+ body = (
532
+ f"- 📄 Tool Result({tr['tool_use_id']}) - {status}\n\n\tcontent: {body_preview}"
533
+ )
534
+ print(body)
535
+
536
+
537
+ def _pp_final(sess: ClaudeSession, theme) -> None:
538
+ usage = sess.usage or {}
539
+ txt = (
540
+ f"### ✅ Session complete - {now_utc().isoformat(timespec='seconds')} UTC\n"
541
+ f"**Result:**\n\n{sess.result or ''}\n\n"
542
+ f"- cost: **${sess.total_cost_usd:.4f}** \n"
543
+ f"- turns: **{sess.num_turns}** \n"
544
+ f"- duration: **{sess.duration_ms} ms** (API {sess.duration_api_ms} ms) \n"
545
+ f"- tokens in/out: {usage.get('input_tokens', 0)}/{usage.get('output_tokens', 0)}"
546
+ )
547
+ print(txt)
548
+
549
+
550
+ # --------------------------------------------------------------------------- internal utils
551
+
552
+
553
+ async def _maybe_await(func, *args, **kw):
554
+ """Call func which may be sync or async."""
555
+ res = func(*args, **kw) if func else None
556
+ if inspect.iscoroutine(res):
557
+ await res
558
+
559
+
560
+ # --------------------------------------------------------------------------- main parser
561
+ async def stream_claude_code_cli(
562
+ request: ClaudeCodeRequest,
563
+ session: ClaudeSession | None = None,
564
+ *,
565
+ on_system: Callable[[dict[str, Any]], None] | None = None,
566
+ on_thinking: Callable[[str], None] | None = None,
567
+ on_text: Callable[[str], None] | None = None,
568
+ on_tool_use: Callable[[dict[str, Any]], None] | None = None,
569
+ on_tool_result: Callable[[dict[str, Any]], None] | None = None,
570
+ on_final: Callable[[ClaudeSession], None] | None = None,
571
+ ) -> AsyncIterator[ClaudeChunk | dict | ClaudeSession]:
572
+ """
573
+ Consume the ND-JSON stream produced by ndjson_from_cli()
574
+ and return a fully-populated ClaudeSession.
575
+
576
+ If callbacks are omitted a default pretty-print is emitted.
577
+ """
578
+ if session is None:
579
+ session = ClaudeSession()
580
+ stream = stream_cc_cli_events(request)
581
+ theme = request.cli_display_theme or "light"
582
+
583
+ async for obj in stream:
584
+ typ = obj.get("type", "unknown")
585
+ chunk = ClaudeChunk(raw=obj, type=typ)
586
+ session.chunks.append(chunk)
587
+
588
+ # ------------------------ SYSTEM -----------------------------------
589
+ if typ == "system":
590
+ data = obj
591
+ session.session_id = data.get("session_id", session.session_id)
592
+ session.model = data.get("model", session.model)
593
+ await _maybe_await(on_system, data)
594
+ if request.verbose:
595
+ _pp_system(data, theme)
596
+ yield data
597
+
598
+ # ------------------------ ASSISTANT --------------------------------
599
+ elif typ == "assistant":
600
+ msg = obj["message"]
601
+ session.messages.append(msg)
602
+
603
+ for blk in msg.get("content", []):
604
+ btype = blk.get("type")
605
+ if btype == "thinking":
606
+ thought = blk.get("thinking", "").strip()
607
+ chunk.thinking = thought
608
+ session.thinking_log.append(thought)
609
+ await _maybe_await(on_thinking, thought)
610
+ if request.verbose:
611
+ _pp_thinking(thought, theme)
612
+
613
+ elif btype == "text":
614
+ text = blk.get("text", "")
615
+ chunk.text = text
616
+ await _maybe_await(on_text, text)
617
+ if request.verbose:
618
+ _pp_assistant_text(text, theme)
619
+
620
+ elif btype == "tool_use":
621
+ tu = {
622
+ "id": blk["id"],
623
+ "name": blk["name"],
624
+ "input": blk["input"],
625
+ }
626
+ chunk.tool_use = tu
627
+ session.tool_uses.append(tu)
628
+ await _maybe_await(on_tool_use, tu)
629
+ if request.verbose:
630
+ _pp_tool_use(tu, theme)
631
+
632
+ elif btype == "tool_result":
633
+ tr = {
634
+ "tool_use_id": blk["tool_use_id"],
635
+ "content": blk["content"],
636
+ "is_error": blk.get("is_error", False),
637
+ }
638
+ chunk.tool_result = tr
639
+ session.tool_results.append(tr)
640
+ await _maybe_await(on_tool_result, tr)
641
+ if request.verbose:
642
+ _pp_tool_result(tr, theme)
643
+ yield chunk
644
+
645
+ # ------------------------ USER (tool_result containers) ------------
646
+ elif typ == "user":
647
+ msg = obj["message"]
648
+ session.messages.append(msg)
649
+ for blk in msg.get("content", []):
650
+ if blk.get("type") == "tool_result":
651
+ tr = {
652
+ "tool_use_id": blk["tool_use_id"],
653
+ "content": blk["content"],
654
+ "is_error": blk.get("is_error", False),
655
+ }
656
+ chunk.tool_result = tr
657
+ session.tool_results.append(tr)
658
+ await _maybe_await(on_tool_result, tr)
659
+ if request.verbose:
660
+ _pp_tool_result(tr, theme)
661
+ yield chunk
662
+
663
+ # ------------------------ RESULT -----------------------------------
664
+ elif typ == "result":
665
+ session.result = obj.get("result", "").strip()
666
+ session.usage = obj.get("usage", {})
667
+ session.total_cost_usd = obj.get("total_cost_usd")
668
+ session.num_turns = obj.get("num_turns")
669
+ session.duration_ms = obj.get("duration_ms")
670
+ session.duration_api_ms = obj.get("duration_api_ms")
671
+ session.is_error = obj.get("is_error", False)
672
+
673
+ # ------------------------ DONE -------------------------------------
674
+ elif typ == "done":
675
+ break
676
+
677
+ # final pretty print
678
+ await _maybe_await(on_final, session)
679
+ if request.verbose:
680
+ _pp_final(session, theme)
681
+
682
+ yield session