aicortexpy 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 (58) hide show
  1. aicortexpy/__init__.py +3 -0
  2. aicortexpy/__main__.py +5 -0
  3. aicortexpy/acp_client.py +559 -0
  4. aicortexpy/acp_conn.py +853 -0
  5. aicortexpy/acp_errors.py +107 -0
  6. aicortexpy/agent_env.py +88 -0
  7. aicortexpy/artifact_ws.py +233 -0
  8. aicortexpy/artifacts.py +67 -0
  9. aicortexpy/attachments.py +85 -0
  10. aicortexpy/auto_commit.py +106 -0
  11. aicortexpy/capabilities.py +92 -0
  12. aicortexpy/chat_acp_pool.py +321 -0
  13. aicortexpy/cli.py +133 -0
  14. aicortexpy/client.py +438 -0
  15. aicortexpy/codex_config.py +98 -0
  16. aicortexpy/config.py +298 -0
  17. aicortexpy/daemon.py +677 -0
  18. aicortexpy/data/default_skills/agent-memory/SKILL.md +67 -0
  19. aicortexpy/data/default_skills/aicorkit-atlassian/SKILL.md +144 -0
  20. aicortexpy/data/default_skills/aicorkit-graph/SKILL.md +145 -0
  21. aicortexpy/data/default_skills/aicorkit-outlook/SKILL.md +87 -0
  22. aicortexpy/data/default_skills/aicorkit-wxmsg/SKILL.md +96 -0
  23. aicortexpy/data/default_skills/aicortex-basics/SKILL.md +10 -0
  24. aicortexpy/data/default_skills/aicortex-bridge-cdp/SKILL.md +118 -0
  25. aicortexpy/data/default_skills/aicortex-cli-ops/SKILL.md +100 -0
  26. aicortexpy/data/default_skills/aicortex-graph/SKILL.md +30 -0
  27. aicortexpy/data/default_skills/graphify/SKILL.md +97 -0
  28. aicortexpy/data/default_skills/question-form/SKILL.md +162 -0
  29. aicortexpy/data/default_skills/test-report-generator/SKILL.md +179 -0
  30. aicortexpy/data/default_skills/test-report-generator/scripts/generate_cases.py +146 -0
  31. aicortexpy/data/default_skills/test-report-generator/scripts/generate_report.py +275 -0
  32. aicortexpy/design_seed.py +59 -0
  33. aicortexpy/diskusage.py +209 -0
  34. aicortexpy/execenv.py +264 -0
  35. aicortexpy/gc.py +286 -0
  36. aicortexpy/handoff_bundle.py +65 -0
  37. aicortexpy/health.py +178 -0
  38. aicortexpy/local_skills.py +157 -0
  39. aicortexpy/models.py +385 -0
  40. aicortexpy/openclaw_config.py +153 -0
  41. aicortexpy/pinned_workdir.py +60 -0
  42. aicortexpy/platform_info.py +61 -0
  43. aicortexpy/poisoned.py +44 -0
  44. aicortexpy/process.py +168 -0
  45. aicortexpy/procutil.py +61 -0
  46. aicortexpy/prompt.py +235 -0
  47. aicortexpy/repo.py +462 -0
  48. aicortexpy/runner.py +969 -0
  49. aicortexpy/runtime_config.py +540 -0
  50. aicortexpy/sandbox.py +264 -0
  51. aicortexpy/stream.py +422 -0
  52. aicortexpy/terminal.py +147 -0
  53. aicortexpy/tunnel.py +82 -0
  54. aicortexpy/wakeup.py +222 -0
  55. aicortexpy-0.1.0.dist-info/METADATA +106 -0
  56. aicortexpy-0.1.0.dist-info/RECORD +58 -0
  57. aicortexpy-0.1.0.dist-info/WHEEL +5 -0
  58. aicortexpy-0.1.0.dist-info/top_level.txt +1 -0
aicortexpy/sandbox.py ADDED
@@ -0,0 +1,264 @@
1
+ """Sandbox / isolation strategy for agent subprocesses.
2
+
3
+ Layers (defense in depth):
4
+ 1. Provider-native — Codex config.toml / Claude permission+sandbox flags
5
+ 2. Optional OS wrapper — Linux bubblewrap, macOS sandbox-exec
6
+ 3. Windows — no bwrap/Seatbelt; rely on (1) + workdir confinement for MVP
7
+
8
+ This mirrors Go execenv/codex_sandbox.go policy, including the macOS
9
+ Seatbelt+network_access caveat (openai/codex#10390).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import os
16
+ import shutil
17
+ import tempfile
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+
21
+ from .platform_info import (
22
+ env_truthy,
23
+ has_bubblewrap,
24
+ has_sandbox_exec,
25
+ host_os,
26
+ )
27
+
28
+ log = logging.getLogger("aicortexpy.sandbox")
29
+
30
+ # Empty = treat macOS Codex network_access as broken (same as Go constant).
31
+ CODEX_DARWIN_NETWORK_ACCESS_FIXED_VERSION = ""
32
+
33
+
34
+ @dataclass
35
+ class CodexSandboxPolicy:
36
+ mode: str # workspace-write | danger-full-access | read-only
37
+ network_access: bool
38
+ reason: str
39
+
40
+
41
+ @dataclass
42
+ class SandboxPlan:
43
+ """How to launch one agent command."""
44
+
45
+ provider: str
46
+ work_dir: Path
47
+ policy_label: str
48
+ env_updates: dict[str, str]
49
+ # If set, replace argv with wrapper + original argv
50
+ wrap_argv: list[str] | None = None
51
+ notes: list[str] | None = None
52
+ # Temp files to delete after run
53
+ cleanup_paths: list[Path] | None = None
54
+
55
+
56
+ def codex_sandbox_policy(detected_version: str = "") -> CodexSandboxPolicy:
57
+ os_name = host_os()
58
+ if os_name != "macos":
59
+ return CodexSandboxPolicy(
60
+ mode="workspace-write",
61
+ network_access=True,
62
+ reason="non-darwin — Landlock/workspace-write OK",
63
+ )
64
+ # darwin: network_access ignored under Seatbelt until upstream fix
65
+ if CODEX_DARWIN_NETWORK_ACCESS_FIXED_VERSION and detected_version:
66
+ # semver compare omitted in MVP — constant empty means always broken
67
+ pass
68
+ return CodexSandboxPolicy(
69
+ mode="danger-full-access",
70
+ network_access=False,
71
+ reason="macos Seatbelt ignores network_access (openai/codex#10390); "
72
+ "use danger-full-access so API calls work",
73
+ )
74
+
75
+
76
+ def write_codex_sandbox_config(codex_home: Path, policy: CodexSandboxPolicy) -> Path:
77
+ """Write minimal config.toml sandbox block into per-task CODEX_HOME."""
78
+ codex_home.mkdir(parents=True, exist_ok=True)
79
+ cfg = codex_home / "config.toml"
80
+ block = (
81
+ "# managed-by: aicortex-python-daemon\n"
82
+ f'sandbox_mode = "{policy.mode}"\n'
83
+ )
84
+ if policy.mode == "workspace-write":
85
+ net = "true" if policy.network_access else "false"
86
+ block += (
87
+ "\n[sandbox_workspace_write]\n"
88
+ f"network_access = {net}\n"
89
+ )
90
+ # Preserve any existing user content below a marker if file already exists.
91
+ existing = ""
92
+ if cfg.is_file():
93
+ text = cfg.read_text(encoding="utf-8")
94
+ marker = "# managed-by: aicortex-python-daemon"
95
+ if marker in text:
96
+ # rewrite managed header only
97
+ rest = text.split(marker, 1)[1]
98
+ # drop until blank line after our block — keep simple: replace whole managed prefix
99
+ existing = ""
100
+ # Keep lines after first blank following old managed block is complex; overwrite.
101
+ _ = rest
102
+ else:
103
+ existing = "\n" + text
104
+ cfg.write_text(block + existing, encoding="utf-8")
105
+ log.info("codex sandbox config written mode=%s reason=%s", policy.mode, policy.reason)
106
+ return cfg
107
+
108
+
109
+ def prepare_provider_sandbox(
110
+ provider: str,
111
+ work_dir: Path,
112
+ *,
113
+ codex_version: str = "",
114
+ ) -> SandboxPlan:
115
+ """Prepare env / config for a provider before spawn."""
116
+ notes: list[str] = []
117
+ env_updates: dict[str, str] = {}
118
+ cleanup: list[Path] = []
119
+ policy_label = "none"
120
+ wrap: list[str] | None = None
121
+
122
+ if provider == "codex":
123
+ policy = codex_sandbox_policy(codex_version)
124
+ policy_label = f"codex:{policy.mode}"
125
+ notes.append(policy.reason)
126
+ codex_home = work_dir.parent / "codex-home"
127
+ write_codex_sandbox_config(codex_home, policy)
128
+ env_updates["CODEX_HOME"] = str(codex_home)
129
+ # Seed auth/config from user home when present (read-only link prefer)
130
+ user_codex = Path.home() / ".codex"
131
+ for name in ("auth.json",):
132
+ src = user_codex / name
133
+ dst = codex_home / name
134
+ if src.is_file() and not dst.exists():
135
+ try:
136
+ shutil.copy2(src, dst)
137
+ except OSError as e:
138
+ notes.append(f"copy {name} failed: {e}")
139
+
140
+ elif provider == "claude":
141
+ policy_label = "claude:permission-mode"
142
+ notes.append(
143
+ "Claude OS sandbox available on macOS/Linux/WSL2 via Claude settings; "
144
+ "Windows native sandbox not available — permission mode only."
145
+ )
146
+ # Encourage workspace-scoped behavior; actual Seatbelt/bwrap is inside Claude.
147
+ env_updates.setdefault("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "0")
148
+
149
+ else:
150
+ policy_label = f"{provider}:workdir-only"
151
+ notes.append("No provider-native sandbox wired; confine cwd to work_dir.")
152
+
153
+ # Optional OS wrapper (off by default — enable aicortex_OS_SANDBOX=1)
154
+ if env_truthy("aicortex_OS_SANDBOX", False):
155
+ wrap, wrap_notes, wrap_cleanup = build_os_wrapper(work_dir)
156
+ notes.extend(wrap_notes)
157
+ cleanup.extend(wrap_cleanup)
158
+ if wrap:
159
+ policy_label = f"{policy_label}+os-wrapper"
160
+
161
+ return SandboxPlan(
162
+ provider=provider,
163
+ work_dir=work_dir,
164
+ policy_label=policy_label,
165
+ env_updates=env_updates,
166
+ wrap_argv=wrap,
167
+ notes=notes,
168
+ cleanup_paths=cleanup,
169
+ )
170
+
171
+
172
+ def build_os_wrapper(work_dir: Path) -> tuple[list[str] | None, list[str], list[Path]]:
173
+ """Return argv prefix to wrap the agent, or None if unavailable."""
174
+ notes: list[str] = []
175
+ cleanup: list[Path] = []
176
+ os_name = host_os()
177
+ root = str(work_dir.resolve())
178
+
179
+ if os_name == "linux" and has_bubblewrap():
180
+ # Minimal bwrap: allow read host, write only work_dir (+ /tmp)
181
+ wrap = [
182
+ "bwrap",
183
+ "--die-with-parent",
184
+ "--unshare-pid",
185
+ "--ro-bind", "/", "/",
186
+ "--dev", "/dev",
187
+ "--proc", "/proc",
188
+ "--tmpfs", "/tmp",
189
+ "--bind", root, root,
190
+ "--chdir", root,
191
+ "--",
192
+ ]
193
+ notes.append("linux bubblewrap wrapper enabled (aicortex_OS_SANDBOX=1)")
194
+ return wrap, notes, cleanup
195
+
196
+ if os_name == "macos" and has_sandbox_exec():
197
+ profile = _macos_seatbelt_profile(root)
198
+ fd, path = tempfile.mkstemp(prefix="aicortex-sb-", suffix=".sb")
199
+ os.close(fd)
200
+ Path(path).write_text(profile, encoding="utf-8")
201
+ cleanup.append(Path(path))
202
+ wrap = ["sandbox-exec", "-f", path]
203
+ notes.append("macos sandbox-exec wrapper enabled")
204
+ return wrap, notes, cleanup
205
+
206
+ if os_name == "windows":
207
+ notes.append(
208
+ "Windows OS wrapper not implemented (no bwrap/Seatbelt). "
209
+ "Use provider sandbox + workdir confinement; consider WSL2 for stronger isolation."
210
+ )
211
+ return None, notes, cleanup
212
+
213
+ notes.append(f"OS sandbox requested but no backend on {os_name}")
214
+ return None, notes, cleanup
215
+
216
+
217
+ def _macos_seatbelt_profile(work_dir: str) -> str:
218
+ # Very small profile: allow most reads, write only work_dir + tmp + user caches.
219
+ wd = work_dir.replace("\\", "/")
220
+ return f"""
221
+ (version 1)
222
+ (deny default)
223
+ (allow process*)
224
+ (allow sysctl-read)
225
+ (allow mach*)
226
+ (allow file-read*)
227
+ (allow file-write* (subpath "{wd}"))
228
+ (allow file-write* (subpath "/private/tmp"))
229
+ (allow file-write* (subpath "/tmp"))
230
+ (allow file-write* (subpath "/private/var/folders"))
231
+ (allow network*)
232
+ """.strip()
233
+
234
+
235
+ def apply_plan_to_command(plan: SandboxPlan, cmd: list[str]) -> list[str]:
236
+ if plan.wrap_argv:
237
+ return list(plan.wrap_argv) + cmd
238
+ return cmd
239
+
240
+
241
+ def cleanup_plan(plan: SandboxPlan) -> None:
242
+ for p in plan.cleanup_paths or []:
243
+ try:
244
+ p.unlink(missing_ok=True)
245
+ except OSError:
246
+ pass
247
+
248
+
249
+ def describe_default_strategy() -> str:
250
+ os_name = host_os()
251
+ lines = [
252
+ f"host_os={os_name}",
253
+ f"bubblewrap={has_bubblewrap()}",
254
+ f"sandbox-exec={has_sandbox_exec()}",
255
+ "layers:",
256
+ " 1) provider-native (Codex config / Claude flags) — default ON",
257
+ " 2) OS wrapper (bwrap / sandbox-exec) — aicortex_OS_SANDBOX=1",
258
+ " 3) Windows: provider + workdir only; prefer WSL2 for OS sandbox",
259
+ ]
260
+ if os_name == "macos":
261
+ lines.append(
262
+ "note: Codex on macOS uses danger-full-access until network_access Seatbelt fix"
263
+ )
264
+ return "\n".join(lines)
aicortexpy/stream.py ADDED
@@ -0,0 +1,422 @@
1
+ """Parse agent CLI stdout into daemon task messages + usage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from dataclasses import dataclass, field
8
+ from typing import Any, Callable
9
+
10
+ log = logging.getLogger("aicortexpy.stream")
11
+
12
+ STREAM_JSON_PROVIDERS = frozenset({"claude", "cursor", "gemini", "opencode", "copilot"})
13
+
14
+
15
+ @dataclass
16
+ class UsageAcc:
17
+ provider: str
18
+ model: str = ""
19
+ input_tokens: int = 0
20
+ output_tokens: int = 0
21
+ cache_read_tokens: int = 0
22
+ cache_write_tokens: int = 0
23
+
24
+ def as_dict(self) -> dict[str, Any]:
25
+ return {
26
+ "provider": self.provider,
27
+ "model": self.model or "unknown",
28
+ "input_tokens": self.input_tokens,
29
+ "output_tokens": self.output_tokens,
30
+ "cache_read_tokens": self.cache_read_tokens,
31
+ "cache_write_tokens": self.cache_write_tokens,
32
+ }
33
+
34
+ @property
35
+ def any(self) -> bool:
36
+ return any(
37
+ (
38
+ self.input_tokens,
39
+ self.output_tokens,
40
+ self.cache_read_tokens,
41
+ self.cache_write_tokens,
42
+ )
43
+ )
44
+
45
+
46
+ @dataclass
47
+ class StreamCollector:
48
+ provider: str
49
+ messages: list[dict[str, Any]] = field(default_factory=list)
50
+ usage: UsageAcc = field(default_factory=lambda: UsageAcc(provider=""))
51
+ text_parts: list[str] = field(default_factory=list)
52
+ session_id: str = ""
53
+ _seq: int = 0
54
+
55
+ def __post_init__(self) -> None:
56
+ self.usage = UsageAcc(provider=self.provider)
57
+
58
+ def _next_seq(self) -> int:
59
+ self._seq += 1
60
+ return self._seq
61
+
62
+ def add_text(self, content: str) -> None:
63
+ content = (content or "").strip()
64
+ if not content:
65
+ return
66
+ self.text_parts.append(content)
67
+ self.messages.append(
68
+ {
69
+ "seq": self._next_seq(),
70
+ "type": "text",
71
+ "content": content[:50_000],
72
+ }
73
+ )
74
+
75
+ def add_tool_use(self, tool: str, input_obj: dict[str, Any] | None = None) -> None:
76
+ msg: dict[str, Any] = {
77
+ "seq": self._next_seq(),
78
+ "type": "tool_use",
79
+ "tool": tool or "tool",
80
+ }
81
+ if input_obj:
82
+ msg["input"] = input_obj
83
+ self.messages.append(msg)
84
+
85
+ def add_tool_result(self, tool: str, output: str) -> None:
86
+ self.messages.append(
87
+ {
88
+ "seq": self._next_seq(),
89
+ "type": "tool_result",
90
+ "tool": tool or "tool",
91
+ "output": (output or "")[:50_000],
92
+ }
93
+ )
94
+
95
+ def feed_line(self, line: str) -> None:
96
+ line = line.strip()
97
+ if not line:
98
+ return
99
+ if self.provider == "claude":
100
+ self._feed_claude_json(line)
101
+ elif self.provider == "cursor":
102
+ self._feed_cursor_json(line)
103
+ elif self.provider == "gemini":
104
+ self._feed_gemini_json(line)
105
+ elif self.provider == "opencode":
106
+ self._feed_opencode_json(line)
107
+ elif self.provider == "copilot":
108
+ self._feed_copilot_json(line)
109
+
110
+ def _feed_claude_json(self, line: str) -> None:
111
+ try:
112
+ obj = json.loads(line)
113
+ except json.JSONDecodeError:
114
+ return
115
+ if not isinstance(obj, dict):
116
+ return
117
+ typ = str(obj.get("type") or "")
118
+
119
+ if typ == "assistant":
120
+ message = obj.get("message") or {}
121
+ content = message.get("content") if isinstance(message, dict) else None
122
+ if isinstance(content, list):
123
+ for block in content:
124
+ if not isinstance(block, dict):
125
+ continue
126
+ btype = block.get("type")
127
+ if btype == "text":
128
+ self.add_text(str(block.get("text") or ""))
129
+ elif btype == "tool_use":
130
+ self.add_tool_use(
131
+ str(block.get("name") or "tool"),
132
+ block.get("input")
133
+ if isinstance(block.get("input"), dict)
134
+ else None,
135
+ )
136
+ usage = message.get("usage") if isinstance(message, dict) else None
137
+ self._merge_usage(usage if isinstance(usage, dict) else None, message)
138
+
139
+ elif typ == "result":
140
+ # Final result event often carries usage + result text
141
+ result = obj.get("result")
142
+ if isinstance(result, str) and result.strip():
143
+ self.add_text(result)
144
+ self._merge_usage(obj.get("usage") if isinstance(obj.get("usage"), dict) else None, obj)
145
+ model = obj.get("model")
146
+ if isinstance(model, str) and model:
147
+ self.usage.model = model
148
+ sid = obj.get("session_id")
149
+ if isinstance(sid, str) and sid.strip():
150
+ self.session_id = sid.strip()
151
+ elif typ == "system":
152
+ sid = obj.get("session_id")
153
+ if isinstance(sid, str) and sid.strip():
154
+ self.session_id = sid.strip()
155
+
156
+ elif typ == "user":
157
+ message = obj.get("message") or {}
158
+ content = message.get("content") if isinstance(message, dict) else None
159
+ if isinstance(content, list):
160
+ for block in content:
161
+ if isinstance(block, dict) and block.get("type") == "tool_result":
162
+ self.add_tool_result(
163
+ str(block.get("tool_use_id") or "tool"),
164
+ str(block.get("content") or ""),
165
+ )
166
+
167
+ def _merge_usage(self, usage: dict[str, Any] | None, parent: dict[str, Any] | None = None) -> None:
168
+ if parent and isinstance(parent.get("model"), str):
169
+ self.usage.model = parent["model"] or self.usage.model
170
+ if not usage:
171
+ return
172
+ self.usage.input_tokens += int(usage.get("input_tokens") or 0)
173
+ self.usage.output_tokens += int(usage.get("output_tokens") or 0)
174
+ self.usage.cache_read_tokens += int(
175
+ usage.get("cache_read_input_tokens")
176
+ or usage.get("cache_read_tokens")
177
+ or 0
178
+ )
179
+ self.usage.cache_write_tokens += int(
180
+ usage.get("cache_creation_input_tokens")
181
+ or usage.get("cache_write_tokens")
182
+ or 0
183
+ )
184
+
185
+ def _feed_cursor_json(self, line: str) -> None:
186
+ try:
187
+ obj = json.loads(line)
188
+ except json.JSONDecodeError:
189
+ return
190
+ if not isinstance(obj, dict):
191
+ return
192
+ sid = obj.get("session_id") or obj.get("sessionId")
193
+ if isinstance(sid, str) and sid.strip():
194
+ self.session_id = sid.strip()
195
+ typ = str(obj.get("type") or "")
196
+
197
+ if typ == "assistant":
198
+ message = obj.get("message")
199
+ if isinstance(message, str):
200
+ try:
201
+ message = json.loads(message)
202
+ except json.JSONDecodeError:
203
+ message = None
204
+ if isinstance(message, dict):
205
+ content = message.get("content")
206
+ if isinstance(content, list):
207
+ for block in content:
208
+ if not isinstance(block, dict):
209
+ continue
210
+ btype = str(block.get("type") or "")
211
+ if btype in ("text", "output_text"):
212
+ self.add_text(str(block.get("text") or ""))
213
+ elif btype == "tool_use":
214
+ self.add_tool_use(
215
+ str(block.get("name") or "tool"),
216
+ block.get("input")
217
+ if isinstance(block.get("input"), dict)
218
+ else None,
219
+ )
220
+ elif typ == "tool_use":
221
+ params = obj.get("parameters") or obj.get("input")
222
+ if isinstance(params, str):
223
+ try:
224
+ params = json.loads(params)
225
+ except json.JSONDecodeError:
226
+ params = {"raw": params}
227
+ self.add_tool_use(
228
+ str(obj.get("tool_name") or obj.get("name") or "tool"),
229
+ params if isinstance(params, dict) else None,
230
+ )
231
+ elif typ == "tool_call":
232
+ title = str(obj.get("title") or obj.get("name") or obj.get("tool") or "tool")
233
+ raw_input = obj.get("rawInput") or obj.get("input") or obj.get("parameters")
234
+ self.add_tool_use(
235
+ title, raw_input if isinstance(raw_input, dict) else None
236
+ )
237
+ elif typ == "tool_result":
238
+ self.add_tool_result(
239
+ str(obj.get("tool_use_id") or obj.get("toolCallId") or "tool"),
240
+ str(obj.get("output") or obj.get("content") or ""),
241
+ )
242
+ elif typ == "text":
243
+ part = obj.get("part")
244
+ if isinstance(part, dict) and part.get("text"):
245
+ self.add_text(str(part["text"]))
246
+ elif obj.get("text"):
247
+ self.add_text(str(obj["text"]))
248
+ elif typ == "result":
249
+ result = obj.get("result") or obj.get("result_text")
250
+ if isinstance(result, str) and result.strip():
251
+ self.add_text(result)
252
+ usage = obj.get("usage")
253
+ if isinstance(usage, dict):
254
+ self._merge_usage(usage, obj)
255
+ model = obj.get("model")
256
+ if isinstance(model, str) and model:
257
+ self.usage.model = model
258
+
259
+ def _feed_gemini_json(self, line: str) -> None:
260
+ try:
261
+ obj = json.loads(line)
262
+ except json.JSONDecodeError:
263
+ return
264
+ if not isinstance(obj, dict):
265
+ return
266
+ typ = str(obj.get("type") or "")
267
+ if typ == "init":
268
+ sid = obj.get("session_id") or obj.get("sessionId")
269
+ if isinstance(sid, str) and sid.strip():
270
+ self.session_id = sid.strip()
271
+ elif typ == "message":
272
+ if str(obj.get("role") or "") == "assistant" and obj.get("content"):
273
+ self.add_text(str(obj["content"]))
274
+ elif typ == "tool_use":
275
+ params = obj.get("parameters") or obj.get("input")
276
+ if isinstance(params, str):
277
+ try:
278
+ params = json.loads(params)
279
+ except json.JSONDecodeError:
280
+ params = None
281
+ self.add_tool_use(
282
+ str(obj.get("tool_name") or obj.get("name") or "tool"),
283
+ params if isinstance(params, dict) else None,
284
+ )
285
+ elif typ == "tool_result":
286
+ self.add_tool_result(
287
+ str(obj.get("tool_id") or obj.get("tool_use_id") or "tool"),
288
+ str(obj.get("output") or ""),
289
+ )
290
+ elif typ == "result":
291
+ stats = obj.get("stats")
292
+ if isinstance(stats, dict):
293
+ models = stats.get("models")
294
+ if isinstance(models, dict):
295
+ for model, m in models.items():
296
+ if not isinstance(m, dict):
297
+ continue
298
+ if isinstance(model, str) and model:
299
+ self.usage.model = model
300
+ self.usage.input_tokens += int(m.get("input_tokens") or m.get("inputTokens") or 0)
301
+ self.usage.output_tokens += int(
302
+ m.get("output_tokens") or m.get("outputTokens") or 0
303
+ )
304
+ self.usage.cache_read_tokens += int(m.get("cached") or 0)
305
+
306
+ def _feed_opencode_json(self, line: str) -> None:
307
+ try:
308
+ obj = json.loads(line)
309
+ except json.JSONDecodeError:
310
+ return
311
+ if not isinstance(obj, dict):
312
+ return
313
+ sid = obj.get("sessionID") or obj.get("sessionId") or obj.get("session_id")
314
+ if isinstance(sid, str) and sid.strip():
315
+ self.session_id = sid.strip()
316
+ typ = str(obj.get("type") or "")
317
+ part = obj.get("part") if isinstance(obj.get("part"), dict) else {}
318
+
319
+ if typ == "text":
320
+ text = str(part.get("text") or obj.get("text") or "")
321
+ if text:
322
+ self.add_text(text)
323
+ elif typ == "tool_use":
324
+ tool = str(part.get("tool") or obj.get("tool") or "tool")
325
+ call_id = str(part.get("callID") or part.get("call_id") or "")
326
+ state = part.get("state") if isinstance(part.get("state"), dict) else {}
327
+ raw_in = state.get("input")
328
+ inp: dict[str, Any] | None = None
329
+ if isinstance(raw_in, dict):
330
+ inp = raw_in
331
+ elif isinstance(raw_in, str):
332
+ try:
333
+ parsed = json.loads(raw_in)
334
+ inp = parsed if isinstance(parsed, dict) else {"text": raw_in}
335
+ except json.JSONDecodeError:
336
+ inp = {"text": raw_in}
337
+ self.add_tool_use(tool, inp)
338
+ if str(state.get("status") or "") == "completed":
339
+ out = state.get("output")
340
+ self.add_tool_result(call_id or tool, str(out or ""))
341
+ elif typ == "step_finish":
342
+ tokens = part.get("tokens") if isinstance(part.get("tokens"), dict) else None
343
+ if tokens:
344
+ self.usage.input_tokens += int(tokens.get("input") or 0)
345
+ self.usage.output_tokens += int(tokens.get("output") or 0)
346
+ cache = tokens.get("cache") if isinstance(tokens.get("cache"), dict) else {}
347
+ self.usage.cache_read_tokens += int(cache.get("read") or 0)
348
+ self.usage.cache_write_tokens += int(cache.get("write") or 0)
349
+
350
+ def _feed_copilot_json(self, line: str) -> None:
351
+ try:
352
+ obj = json.loads(line)
353
+ except json.JSONDecodeError:
354
+ return
355
+ if not isinstance(obj, dict):
356
+ return
357
+ typ = str(obj.get("type") or "")
358
+ data = obj.get("data") if isinstance(obj.get("data"), dict) else {}
359
+
360
+ if typ == "session.start":
361
+ sid = data.get("sessionId") or data.get("session_id")
362
+ if isinstance(sid, str) and sid.strip():
363
+ self.session_id = sid.strip()
364
+ model = data.get("selectedModel") or data.get("model")
365
+ if isinstance(model, str) and model:
366
+ self.usage.model = model
367
+ elif typ == "assistant.message_delta":
368
+ delta = str(data.get("deltaContent") or data.get("content") or "")
369
+ if delta:
370
+ self.add_text(delta)
371
+ elif typ == "assistant.message":
372
+ content = str(data.get("content") or "")
373
+ if content and not self.text_parts:
374
+ self.add_text(content)
375
+ reasoning = str(data.get("reasoningText") or "")
376
+ if reasoning:
377
+ self.messages.append(
378
+ {"seq": self._next_seq(), "type": "thinking", "content": reasoning[:50_000]}
379
+ )
380
+ reqs = data.get("toolRequests")
381
+ if isinstance(reqs, list):
382
+ for tr in reqs:
383
+ if not isinstance(tr, dict):
384
+ continue
385
+ self.add_tool_use(
386
+ str(tr.get("name") or tr.get("tool") or "tool"),
387
+ tr.get("arguments") if isinstance(tr.get("arguments"), dict) else None,
388
+ )
389
+ out_tok = data.get("outputTokens")
390
+ if out_tok is not None:
391
+ self.usage.output_tokens += int(out_tok or 0)
392
+ elif typ in ("assistant.reasoning", "assistant.reasoning_delta"):
393
+ text = str(data.get("content") or data.get("deltaContent") or "")
394
+ if text:
395
+ self.messages.append(
396
+ {"seq": self._next_seq(), "type": "thinking", "content": text[:50_000]}
397
+ )
398
+ elif typ == "tool.execution_complete":
399
+ call_id = str(data.get("toolCallId") or data.get("tool_call_id") or "tool")
400
+ result = data.get("result") if isinstance(data.get("result"), dict) else {}
401
+ out = str(result.get("content") or data.get("error", {}).get("message") or "")
402
+ if not out and isinstance(data.get("error"), dict):
403
+ out = str(data["error"].get("message") or "")
404
+ self.add_tool_result(call_id, out)
405
+ elif typ == "result":
406
+ sid = obj.get("sessionId") or data.get("sessionId")
407
+ if isinstance(sid, str) and sid.strip():
408
+ self.session_id = sid.strip()
409
+
410
+ def finalize_plain_output(self, text: str) -> None:
411
+ """For non-JSON providers: one text message from full stdout."""
412
+ if self.messages:
413
+ return
414
+ self.add_text(text)
415
+
416
+ def output_text(self) -> str:
417
+ if self.text_parts:
418
+ return "\n".join(self.text_parts)
419
+ return ""
420
+
421
+
422
+ MessageSink = Callable[[list[dict[str, Any]]], None]