ags-cli 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 (156) hide show
  1. ags_cli-0.1.0.dist-info/METADATA +332 -0
  2. ags_cli-0.1.0.dist-info/RECORD +156 -0
  3. ags_cli-0.1.0.dist-info/WHEEL +5 -0
  4. ags_cli-0.1.0.dist-info/entry_points.txt +2 -0
  5. ags_cli-0.1.0.dist-info/top_level.txt +2 -0
  6. cli/__init__.py +0 -0
  7. cli/__main__.py +4 -0
  8. cli/client.py +145 -0
  9. cli/commands/__init__.py +0 -0
  10. cli/commands/adapter.py +33 -0
  11. cli/commands/artifact.py +21 -0
  12. cli/commands/ask.py +212 -0
  13. cli/commands/chat.py +649 -0
  14. cli/commands/diff.py +49 -0
  15. cli/commands/doctor.py +93 -0
  16. cli/commands/gui.py +45 -0
  17. cli/commands/init.py +20 -0
  18. cli/commands/mcp.py +50 -0
  19. cli/commands/memory.py +65 -0
  20. cli/commands/model.py +73 -0
  21. cli/commands/policy.py +52 -0
  22. cli/commands/project.py +122 -0
  23. cli/commands/quota.py +107 -0
  24. cli/commands/run.py +84 -0
  25. cli/commands/serve.py +171 -0
  26. cli/commands/service.py +236 -0
  27. cli/commands/session.py +219 -0
  28. cli/commands/stats.py +96 -0
  29. cli/commands/status.py +36 -0
  30. cli/commands/task.py +37 -0
  31. cli/commands/usage.py +86 -0
  32. cli/local.py +84 -0
  33. cli/main.py +149 -0
  34. harness/__init__.py +4 -0
  35. harness/adapters/__init__.py +26 -0
  36. harness/adapters/antigravity.py +301 -0
  37. harness/adapters/claude_code.py +522 -0
  38. harness/adapters/local_openai.py +534 -0
  39. harness/adapters/mock.py +112 -0
  40. harness/adapters/probe.py +65 -0
  41. harness/adapters/registry.py +56 -0
  42. harness/adapters/warm_pool.py +255 -0
  43. harness/api/__init__.py +0 -0
  44. harness/api/router.py +38 -0
  45. harness/api/v1/__init__.py +0 -0
  46. harness/api/v1/adapters.py +105 -0
  47. harness/api/v1/approvals.py +173 -0
  48. harness/api/v1/artifacts.py +44 -0
  49. harness/api/v1/attachments.py +48 -0
  50. harness/api/v1/doctor.py +22 -0
  51. harness/api/v1/health.py +37 -0
  52. harness/api/v1/mcp.py +131 -0
  53. harness/api/v1/memory.py +80 -0
  54. harness/api/v1/policies.py +49 -0
  55. harness/api/v1/project.py +302 -0
  56. harness/api/v1/runs.py +98 -0
  57. harness/api/v1/sessions.py +248 -0
  58. harness/api/v1/stream.py +110 -0
  59. harness/api/v1/tasks.py +30 -0
  60. harness/api/v1/usage.py +58 -0
  61. harness/bootstrap.py +216 -0
  62. harness/db.py +164 -0
  63. harness/deps.py +58 -0
  64. harness/eventbus/__init__.py +20 -0
  65. harness/eventbus/inprocess_bus.py +85 -0
  66. harness/main.py +144 -0
  67. harness/mcp/__init__.py +22 -0
  68. harness/mcp/client.py +139 -0
  69. harness/mcp/config_gen.py +77 -0
  70. harness/mcp/registry.py +156 -0
  71. harness/mcp/tools.py +81 -0
  72. harness/memory/__init__.py +13 -0
  73. harness/memory/context_budget.py +116 -0
  74. harness/memory/embed.py +217 -0
  75. harness/memory/extract.py +74 -0
  76. harness/memory/ingest.py +106 -0
  77. harness/memory/namespaces.py +57 -0
  78. harness/memory/ranking.py +31 -0
  79. harness/memory/redact.py +37 -0
  80. harness/memory/service.py +320 -0
  81. harness/memory/sqlite_vec_backend.py +266 -0
  82. harness/memory/summarize.py +68 -0
  83. harness/models/__init__.py +35 -0
  84. harness/models/adapter_health.py +27 -0
  85. harness/models/approval.py +37 -0
  86. harness/models/artifact.py +35 -0
  87. harness/models/audit_log.py +33 -0
  88. harness/models/comparison.py +33 -0
  89. harness/models/enums.py +146 -0
  90. harness/models/mcp_server_config.py +49 -0
  91. harness/models/memory.py +76 -0
  92. harness/models/policy.py +29 -0
  93. harness/models/provider_config.py +35 -0
  94. harness/models/run.py +46 -0
  95. harness/models/run_event.py +35 -0
  96. harness/models/session.py +50 -0
  97. harness/models/task.py +30 -0
  98. harness/models/types.py +63 -0
  99. harness/models/workspace.py +27 -0
  100. harness/observability/__init__.py +4 -0
  101. harness/observability/audit.py +88 -0
  102. harness/observability/logging.py +47 -0
  103. harness/observability/metrics.py +43 -0
  104. harness/observability/tracing.py +38 -0
  105. harness/orchestrator/__init__.py +19 -0
  106. harness/orchestrator/engine.py +821 -0
  107. harness/orchestrator/handoff.py +33 -0
  108. harness/orchestrator/lifecycle.py +69 -0
  109. harness/orchestrator/native_resume.py +93 -0
  110. harness/orchestrator/policies.py +101 -0
  111. harness/orchestrator/reconcile.py +39 -0
  112. harness/orchestrator/run_graph.py +62 -0
  113. harness/orchestrator/snapshot.py +49 -0
  114. harness/schemas/__init__.py +1 -0
  115. harness/schemas/adapter.py +26 -0
  116. harness/schemas/approval.py +25 -0
  117. harness/schemas/artifact.py +17 -0
  118. harness/schemas/common.py +20 -0
  119. harness/schemas/memory.py +50 -0
  120. harness/schemas/policy.py +39 -0
  121. harness/schemas/run.py +116 -0
  122. harness/schemas/session.py +93 -0
  123. harness/schemas/task.py +24 -0
  124. harness/security/__init__.py +5 -0
  125. harness/security/approval_policy.py +107 -0
  126. harness/security/auth.py +106 -0
  127. harness/security/permissions.py +59 -0
  128. harness/security/risk.py +59 -0
  129. harness/security/secrets.py +49 -0
  130. harness/services/__init__.py +1 -0
  131. harness/services/adapters_health.py +212 -0
  132. harness/services/approvals.py +84 -0
  133. harness/services/artifacts.py +78 -0
  134. harness/services/attachments.py +228 -0
  135. harness/services/comparison.py +345 -0
  136. harness/services/doctor.py +156 -0
  137. harness/services/files.py +287 -0
  138. harness/services/mcp_servers.py +179 -0
  139. harness/services/project_git.py +101 -0
  140. harness/services/retention.py +97 -0
  141. harness/services/runs.py +155 -0
  142. harness/services/runs_diff.py +201 -0
  143. harness/services/sessions.py +242 -0
  144. harness/services/ssh.py +300 -0
  145. harness/services/stats.py +132 -0
  146. harness/services/tasks.py +41 -0
  147. harness/services/workspaces.py +184 -0
  148. harness/services/worktrees.py +439 -0
  149. harness/settings.py +186 -0
  150. harness/skills/__init__.py +11 -0
  151. harness/skills/manager.py +141 -0
  152. harness/tools/__init__.py +1 -0
  153. harness/tools/fs_tools.py +295 -0
  154. harness/workers/__init__.py +0 -0
  155. harness/workers/dispatch.py +26 -0
  156. harness/workers/inprocess.py +135 -0
@@ -0,0 +1,522 @@
1
+ """ClaudeCodeAdapter — drives the official ``claude`` CLI (verified against
2
+ v2.1.187).
3
+
4
+ Invocation (no token hijacking — the CLI uses its own supported auth; an optional
5
+ ``ANTHROPIC_API_KEY`` is injected from a ``secret_ref``):
6
+
7
+ claude --print --output-format stream-json --verbose \
8
+ --model <model> --permission-mode <mode> --add-dir <cwd> \
9
+ --session-id <run_id> --append-system-prompt <system+memory> "<message>"
10
+
11
+ ``--output-format stream-json`` emits NDJSON; we map each line onto the
12
+ ``NormalizedEvent`` union. The terminal ``{"type":"result", ...}`` object (schema
13
+ confirmed empirically) carries the final text, ``session_id``, ``total_cost_usd``
14
+ and token ``usage``.
15
+
16
+ Warm-pool path (HARNESS_WARM_CLI=1):
17
+ When enabled, same-session turns are routed through ``WarmCliPool`` so the
18
+ ``claude`` process is kept alive between turns and the CLI's KV cache is reused,
19
+ reducing turn-2+ latency.
20
+
21
+ Respawn-resumes-on-disk (verified empirically, claude 2.1.190): a FRESH
22
+ ``claude --print --verbose --input-format stream-json --output-format
23
+ stream-json --resume <session_id>`` process RESUMES the on-disk conversation
24
+ created by a previous (now-dead) process — the resumed process correctly
25
+ recalls turn-1 content. So a warm-proc respawn after an idle-reap does NOT
26
+ need engine-side transcript replay: the warm argv includes ``--resume
27
+ <native_session_id>`` whenever a native id is known. The first-ever spawn of a
28
+ session has no native id → fresh conversation; the engine captures the
29
+ ``session_id`` from the init event as before; any respawn gets the native id
30
+ from the engine and resumes the on-disk conversation.
31
+
32
+ Fail-closed mid-turn: a warm turn that dies AFTER we have already yielded parsed
33
+ events (a partial answer reached the bus) does NOT fall back to a fresh spawn
34
+ (that would double-emit the answer to the ledger/bus). Instead we emit an error
35
+ + failed status, kill the broken proc (next turn respawns and ``--resume``s),
36
+ and let the engine persist a failed run. Only a death BEFORE any event was
37
+ yielded falls back to the one-shot spawn path (the answer never reached the bus).
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import contextlib
43
+ import itertools
44
+ import json
45
+ import shutil
46
+ import tempfile
47
+ from collections.abc import AsyncIterator, Iterable
48
+ from pathlib import Path
49
+
50
+ from harness_sdk.base import Capabilities, Cost, ProviderSession, SessionContext, Usage
51
+ from harness_sdk.cli_base import CliSubprocessAdapter, ParserState
52
+ from harness.adapters.probe import probe_cli, supports_flag
53
+ from harness_sdk.events import (
54
+ AdapterMetaEvent,
55
+ CostEvent,
56
+ ErrorEvent,
57
+ MessageEvent,
58
+ NormalizedEvent,
59
+ StatusEvent,
60
+ ToolCallEvent,
61
+ ToolResultEvent,
62
+ )
63
+
64
+ from harness.models.enums import AdapterKind
65
+ from harness.observability.logging import get_logger
66
+
67
+ _log = get_logger("claude-code-adapter")
68
+
69
+ # Public Anthropic list price for Opus-class models ($/token). Override per
70
+ # provider via ``params.price_in`` / ``params.price_out`` for other models.
71
+ _DEFAULT_PRICE_IN = 15.0 / 1_000_000
72
+ _DEFAULT_PRICE_OUT = 75.0 / 1_000_000
73
+
74
+
75
+ class ClaudeCodeAdapter(CliSubprocessAdapter):
76
+ kind = AdapterKind.claude_code
77
+ default_use_pty = False # claude streams fine on a plain pipe
78
+
79
+ def __init__(self, config) -> None: # noqa: ANN001 - AdapterConfig
80
+ super().__init__(config)
81
+ # Per-key dirs holding generated .mcp.json (with resolved secrets); keyed by
82
+ # run_id for one-shot runs, by pool key for warm procs. One-shot dirs are
83
+ # removed in _reap; warm dirs via the pool's on_close callback.
84
+ self._mcp_dirs: dict[str, str] = {}
85
+ # Active warm turns: run_id → pool key, so cancel_run can reach the warm proc.
86
+ self._warm_runs: dict[str, str] = {}
87
+ self._probe: dict | None = None
88
+
89
+ async def ensure_probe(self) -> dict:
90
+ """Run ``probe_cli`` once, cache the result on the instance, and return it."""
91
+ if self._probe is not None:
92
+ return self._probe
93
+ try:
94
+ result = await probe_cli(self.binary)
95
+ except Exception:
96
+ return {"ok": False, "flags": set(), "version": None}
97
+ self._probe = result
98
+ return self._probe
99
+
100
+ @property
101
+ def binary(self) -> str:
102
+ return self.config.params.get("bin", "claude")
103
+
104
+ def env_overrides(self) -> dict[str, str]:
105
+ env: dict[str, str] = {}
106
+ if self.config.api_key:
107
+ env["ANTHROPIC_API_KEY"] = self.config.api_key
108
+ # Opt-in managed-skill home (off by default; relocating the config dir means the
109
+ # CLI uses the harness-supplied API key rather than the user's own login).
110
+ from harness.skills import provision_config_home
111
+ if (home := provision_config_home(self.config.name)):
112
+ env[self.config.params.get("config_dir_env", "CLAUDE_CONFIG_DIR")] = home
113
+ return env
114
+
115
+ def health_argv(self) -> list[str]:
116
+ return [self.binary, "--version"]
117
+
118
+ async def list_models(self) -> list[str]:
119
+ """The ``claude`` CLI has no machine-readable model list — no subcommand
120
+ enumerates it and ``--model`` only documents the alias forms — so we
121
+ surface a known set instead. It is a static snapshot: it does NOT track
122
+ the models your CLI login is actually entitled to, and it goes stale as
123
+ new models ship. Override it with ``params.models`` in config.yaml
124
+ (that list is used verbatim), which is also how you expose account- or
125
+ CLI-specific variants such as ``claude-fable-5[1m]``.
126
+
127
+ The CLI also accepts short aliases (``opus``/``sonnet``/``haiku``/
128
+ ``fable``) for ``--model``, which always resolve to the latest model in
129
+ that tier."""
130
+ configured = self.config.params.get("models")
131
+ if configured:
132
+ return list(configured)
133
+ return [
134
+ "claude-fable-5",
135
+ "claude-opus-4-8",
136
+ "claude-opus-4-7",
137
+ "claude-sonnet-5",
138
+ "claude-sonnet-4-6",
139
+ "claude-haiku-4-5",
140
+ ]
141
+
142
+ @staticmethod
143
+ def _image_attachments(ctx: SessionContext | None) -> list[dict]:
144
+ return list((ctx.extra or {}).get("attachments_images") or []) if ctx else []
145
+
146
+ def _user_content(self, message: str, ctx: SessionContext | None) -> list[dict]:
147
+ """Content-block array for a user turn: the text plus any image
148
+ attachments as base64 blocks. A vanished/unreadable file is skipped with
149
+ a warning — an attachment must never sink the run."""
150
+ import base64
151
+ import logging
152
+
153
+ content: list[dict] = [{"type": "text", "text": message}]
154
+ for att in self._image_attachments(ctx):
155
+ try:
156
+ data = Path(att["path"]).read_bytes()
157
+ except OSError as exc:
158
+ logging.getLogger(__name__).warning(
159
+ "attachment image unreadable, skipping: %s (%s)", att.get("path"), exc
160
+ )
161
+ continue
162
+ content.append({
163
+ "type": "image",
164
+ "source": {
165
+ "type": "base64",
166
+ "media_type": att.get("media_type", "image/png"),
167
+ "data": base64.b64encode(data).decode(),
168
+ },
169
+ })
170
+ return content
171
+
172
+ def stdin_payload(self, message: str, ctx: SessionContext | None = None) -> bytes | None:
173
+ # Feed the prompt via stdin so it can't be swallowed by the variadic
174
+ # ``--add-dir`` flag (verified failure mode with v2.1.187).
175
+ if self._image_attachments(ctx):
176
+ # Image blocks need the stream-json input format (see build_argv).
177
+ line = json.dumps({
178
+ "type": "user",
179
+ "message": {"role": "user", "content": self._user_content(message, ctx)},
180
+ })
181
+ return (line + "\n").encode()
182
+ return message.encode()
183
+
184
+ def _permission_mode(self, ctx: SessionContext) -> str:
185
+ """Resolve the effective ``--permission-mode`` for this context. Safe by
186
+ default (plan = no edits); write mode raises it to acceptEdits. The effective
187
+ mode is the per-run setting on the context (workspace mode), set by the engine.
188
+ """
189
+ p = self.config.params
190
+ mode = p.get("permission_mode", "plan")
191
+ if not ctx.read_only:
192
+ mode = p.get("write_permission_mode", "acceptEdits")
193
+ return mode
194
+
195
+ def build_argv(self, *, run_id: str, ctx: SessionContext, message: str) -> list[str]:
196
+ p = self.config.params
197
+ mode = self._permission_mode(ctx)
198
+ nsid = ctx.extra.get("native_session_id")
199
+ if nsid:
200
+ session_args = ["--resume", str(nsid)]
201
+ else:
202
+ session_args = ["--session-id", run_id]
203
+ # Image attachments ride as content blocks, which require stream-json input.
204
+ input_format = "stream-json" if self._image_attachments(ctx) else "text"
205
+ argv = [
206
+ self.binary, "--print",
207
+ "--output-format", "stream-json", "--verbose",
208
+ "--input-format", input_format,
209
+ "--permission-mode", mode,
210
+ *session_args,
211
+ ]
212
+ if self.config.model:
213
+ argv += ["--model", self.config.model]
214
+ if (cwd := self.working_dir(ctx)):
215
+ argv += ["--add-dir", cwd]
216
+ if p.get("dangerously_skip_permissions"):
217
+ argv.append("--dangerously-skip-permissions")
218
+ argv += self._mcp_argv(run_id, ctx)
219
+ system = "\n\n".join(x for x in (ctx.system_prompt, ctx.memory_context) if x)
220
+ if system:
221
+ argv += ["--append-system-prompt", system]
222
+ # Prompt is supplied via stdin (see stdin_payload) — not a positional arg.
223
+ return argv
224
+
225
+ def _mcp_argv(self, key: str, ctx: SessionContext) -> list[str]:
226
+ """Claude is its own MCP client: write the harness-resolved servers to a
227
+ ``.mcp.json`` and point the CLI at it. ``--strict-mcp-config`` makes the CLI
228
+ use ONLY these servers (ignoring any project/user ``.mcp.json``), and
229
+ ``--allowedTools`` pre-authorizes their tools so they run under plan/acceptEdits
230
+ without an interactive prompt.
231
+
232
+ ``key`` scopes the generated dir: a ``run_id`` for one-shot runs, a pool key
233
+ for warm procs. If a dir already exists for this key (e.g. a warm proc being
234
+ respawned into a new argv), it is removed first so resolved-secret temp dirs
235
+ don't leak."""
236
+ from harness.mcp import config_gen
237
+
238
+ servers = ctx.extra.get("mcp_servers") or []
239
+ if not servers:
240
+ return []
241
+ if self.remote_host(ctx):
242
+ # The generated .mcp.json is a local temp file the remote CLI can't
243
+ # read — MCP injection is a local-only feature for now.
244
+ _log.warning("mcp_skipped_remote", host=self.remote_host(ctx),
245
+ servers=len(servers))
246
+ return []
247
+
248
+ # Probe gating for MCP config flag
249
+ explicit_flag = self.config.params.get("mcp_config_flag")
250
+ flag = explicit_flag or "--mcp-config"
251
+ if not explicit_flag and self._probe is not None:
252
+ if not supports_flag(self._probe, flag):
253
+ _log.warning("Skipping MCP servers: flag %r not found in %r help output", flag, self.binary)
254
+ return []
255
+
256
+ # Remove any prior dir for this key before creating a new one (leak guard).
257
+ if (old := self._mcp_dirs.pop(key, None)):
258
+ shutil.rmtree(old, ignore_errors=True)
259
+ d = tempfile.mkdtemp(prefix=f"ags-mcp-{key[:8]}-")
260
+ self._mcp_dirs[key] = d
261
+ path = config_gen.write_json(config_gen.claude_mcp_config(servers), d, "mcp.json")
262
+ argv = [flag, path, "--strict-mcp-config"]
263
+
264
+ # Check if --allowedTools is supported
265
+ if (patterns := config_gen.allowed_tool_patterns(servers)):
266
+ tools_flag = "--allowedTools"
267
+ if not explicit_flag and self._probe is not None and not supports_flag(self._probe, tools_flag):
268
+ _log.warning("Skipping allowed tools pre-authorization: flag %r not supported", tools_flag)
269
+ else:
270
+ argv += [tools_flag, " ".join(patterns)]
271
+ return argv
272
+
273
+ def _drop_mcp_dir(self, key: str) -> None:
274
+ """Remove the generated MCP dir for ``key`` (it may hold resolved secrets)."""
275
+ if (d := self._mcp_dirs.pop(key, None)):
276
+ shutil.rmtree(d, ignore_errors=True)
277
+
278
+ def build_warm_argv(self, *, ctx: SessionContext) -> list[str]:
279
+ """Build the argv for the warm-pool process: like ``build_argv`` but uses
280
+ ``--input-format stream-json`` (persistent stdin protocol).
281
+
282
+ When a ``native_session_id`` is present in ``ctx.extra`` (any turn after the
283
+ first-ever spawn of the session), the warm argv includes ``--resume
284
+ <native_session_id>`` so a freshly respawned process (after an idle-reap)
285
+ resumes the on-disk conversation created by the previous (dead) process —
286
+ verified empirically. The first-ever spawn has no native id → fresh
287
+ conversation; the engine captures the id from the init event as before. We
288
+ never pass ``--session-id`` on the warm path (a warm process tracks one
289
+ conversation for its lifetime; on respawn ``--resume`` restores it).
290
+ """
291
+ p = self.config.params
292
+ mode = self._permission_mode(ctx)
293
+ argv = [
294
+ self.binary, "--print",
295
+ "--output-format", "stream-json", "--verbose",
296
+ "--input-format", "stream-json",
297
+ "--permission-mode", mode,
298
+ ]
299
+ nsid = ctx.extra.get("native_session_id")
300
+ if nsid:
301
+ argv += ["--resume", str(nsid)]
302
+ if self.config.model:
303
+ argv += ["--model", self.config.model]
304
+ if (cwd := self.working_dir(ctx)):
305
+ argv += ["--add-dir", cwd]
306
+ if p.get("dangerously_skip_permissions"):
307
+ argv.append("--dangerously-skip-permissions")
308
+ # MCP: scope the generated .mcp.json to the pool key (session:mode) so it is
309
+ # created once per warm proc and torn down by the pool's on_close callback.
310
+ argv += self._mcp_argv(f"{ctx.session_id}:{mode}", ctx)
311
+ system = "\n\n".join(x for x in (ctx.system_prompt, ctx.memory_context) if x)
312
+ if system:
313
+ argv += ["--append-system-prompt", system]
314
+ return argv
315
+
316
+ async def stream_events(
317
+ self,
318
+ session: ProviderSession,
319
+ message: str,
320
+ *,
321
+ tools=None,
322
+ stream: bool = True,
323
+ ) -> AsyncIterator[NormalizedEvent]:
324
+ """Override to optionally route through the warm pool.
325
+
326
+ When ``settings.warm_cli`` is True the turn is routed through ``WarmCliPool``.
327
+ A warm-turn death BEFORE any parsed event has been yielded falls back to the
328
+ one-shot spawn path (the answer never reached the bus). A death AFTER events
329
+ were yielded is fail-closed: emit an error + failed status and kill the broken
330
+ proc (so the next turn respawns and ``--resume``s the on-disk conversation)
331
+ rather than double-emitting a second answer to the ledger/bus.
332
+ """
333
+ from harness.settings import get_settings
334
+
335
+ settings = get_settings()
336
+ # Remote (ssh) sessions always take the one-shot path: the warm pool spawns
337
+ # local processes, and Claude's session state lives on the remote host anyway
338
+ # (--resume works there across one-shot invocations).
339
+ remote = bool((session.meta.get("extra") or {}).get("ssh_host"))
340
+ if not settings.warm_cli or remote:
341
+ async for ev in super().stream_events(session, message, tools=tools, stream=stream):
342
+ yield ev
343
+ return
344
+
345
+ run_id = session.provider_session_id
346
+ extra = session.meta.get("extra") or {}
347
+ session_id = session.meta.get("session_id", run_id)
348
+
349
+ # Build context for warm argv.
350
+ ctx = SessionContext(
351
+ run_id=run_id,
352
+ session_id=session_id,
353
+ workspace_slug=session.meta.get("workspace_slug", "default"),
354
+ objective=message,
355
+ system_prompt=session.meta.get("system_prompt"),
356
+ memory_context=session.meta.get("memory_context", ""),
357
+ workspace_dir=session.meta.get("workspace_dir"),
358
+ read_only=session.meta.get("read_only", True),
359
+ extra=extra,
360
+ )
361
+ # Pool key includes the resolved permission mode so a plan→write switch spawns
362
+ # a separate warm proc (the old one is idle-reaped); context is restored on the
363
+ # fresh proc via --resume.
364
+ mode = self._permission_mode(ctx)
365
+ pool_key = f"{session_id}:{mode}"
366
+ argv = self.build_warm_argv(ctx=ctx)
367
+ env = self.env_overrides()
368
+
369
+ from harness.adapters.warm_pool import get_warm_pool
370
+
371
+ pool = get_warm_pool()
372
+ yielded_any = False
373
+ self._warm_runs[run_id] = pool_key
374
+ try:
375
+ proc = await pool.acquire(
376
+ pool_key, argv, env,
377
+ on_close=lambda: self._drop_mcp_dir(pool_key),
378
+ )
379
+ user_line = json.dumps({
380
+ "type": "user",
381
+ "message": {"role": "user", "content": self._user_content(message, ctx)},
382
+ })
383
+ seq = itertools.count()
384
+ state: ParserState = {"text": []}
385
+
386
+ def _tag(ev: NormalizedEvent) -> NormalizedEvent:
387
+ ev.seq = next(seq)
388
+ return ev
389
+
390
+ yield _tag(StatusEvent(seq=0, status="running"))
391
+ async for line in pool.send_turn(proc, user_line):
392
+ for ev in self.parse_chunk(line, state):
393
+ yielded_any = True
394
+ yield _tag(ev)
395
+ for ev in self.finalize(state):
396
+ yielded_any = True
397
+ yield _tag(ev)
398
+ yield _tag(StatusEvent(seq=0, status="succeeded"))
399
+ return
400
+ except Exception as exc: # noqa: BLE001 — fail soft (pre-yield) / fail closed (mid-turn)
401
+ if yielded_any:
402
+ # A partial answer already reached the bus/ledger. Falling back to a
403
+ # fresh one-shot spawn would double-emit the answer — instead fail
404
+ # closed: kill the broken proc (next turn respawns + --resume) and let
405
+ # the engine persist a failed run. No duplicate answer ever reaches
406
+ # the ledger/bus.
407
+ _log.warning("warm_turn_failed_mid_stream", error=str(exc),
408
+ session_id=session_id, key=pool_key)
409
+ with contextlib.suppress(Exception):
410
+ await pool.kill(pool_key)
411
+ yield ErrorEvent(
412
+ seq=0, message=f"warm turn failed mid-stream: {exc}", retriable=True
413
+ )
414
+ yield StatusEvent(seq=0, status="failed")
415
+ return
416
+ _log.warning("warm_pool_error_fallback", error=str(exc), session_id=session_id)
417
+ # Nothing yielded yet → safe to fall through to the normal spawn path.
418
+ finally:
419
+ self._warm_runs.pop(run_id, None)
420
+
421
+ async for ev in super().stream_events(session, message, tools=tools, stream=stream):
422
+ yield ev
423
+
424
+ async def cancel_run(self, run_id: str) -> None:
425
+ # If this run is an in-flight warm turn, kill its warm proc so the CLI stops
426
+ # generating; the next turn respawns and --resumes the on-disk conversation.
427
+ key = self._warm_runs.pop(run_id, None)
428
+ if key is not None:
429
+ from harness.adapters.warm_pool import get_warm_pool
430
+ with contextlib.suppress(Exception):
431
+ await get_warm_pool().kill(key)
432
+ # Also run the base cancellation for the one-shot spawn path.
433
+ await super().cancel_run(run_id)
434
+
435
+ async def _reap(self, proc, run_id: str) -> None: # noqa: ANN001
436
+ # Remove the run's generated MCP config (it may hold resolved secrets) once the
437
+ # subprocess is gone, then run the base reap (process-group teardown).
438
+ self._drop_mcp_dir(run_id)
439
+ await super()._reap(proc, run_id)
440
+
441
+ def parse_chunk(self, line: str, state: ParserState) -> Iterable[NormalizedEvent]:
442
+ line = line.strip()
443
+ if not line:
444
+ return
445
+ try:
446
+ obj = json.loads(line)
447
+ except json.JSONDecodeError:
448
+ return # non-JSON noise (shouldn't happen with stream-json)
449
+ t = obj.get("type")
450
+
451
+ if t == "system":
452
+ yield AdapterMetaEvent(seq=0, data={"subtype": obj.get("subtype"),
453
+ "session_id": obj.get("session_id")})
454
+ elif t == "assistant":
455
+ for block in obj.get("message", {}).get("content", []):
456
+ bt = block.get("type")
457
+ if bt == "text" and block.get("text"):
458
+ state["had_message"] = True
459
+ yield MessageEvent(seq=0, role="assistant", text=block["text"])
460
+ elif bt == "tool_use":
461
+ yield ToolCallEvent(seq=0, name=block.get("name", ""),
462
+ arguments=block.get("input", {}) or {},
463
+ call_id=block.get("id"))
464
+ elif t == "user":
465
+ for block in obj.get("message", {}).get("content", []):
466
+ if block.get("type") == "tool_result":
467
+ yield ToolResultEvent(
468
+ seq=0, call_id=block.get("tool_use_id"),
469
+ output=_stringify(block.get("content")),
470
+ is_error=bool(block.get("is_error")),
471
+ )
472
+ elif t == "result":
473
+ is_error = obj.get("is_error")
474
+ result_content = obj.get("result") or obj.get("content") or obj.get("text")
475
+ if is_error:
476
+ yield ErrorEvent(seq=0, message=str(result_content or "claude error"),
477
+ retriable=obj.get("api_error_status") in (429, 500, 503))
478
+ elif not state.get("had_message") and result_content:
479
+ state["had_message"] = True
480
+ yield MessageEvent(seq=0, role="assistant", text=str(result_content))
481
+ usage = obj.get("usage", {}) or {}
482
+ prompt_tokens = usage.get("input_tokens") or usage.get("prompt_tokens") or 0
483
+ completion_tokens = usage.get("output_tokens") or usage.get("completion_tokens") or 0
484
+ cost_usd = obj.get("total_cost_usd")
485
+ if cost_usd is None:
486
+ cost_usd = obj.get("cost_usd") or obj.get("cost")
487
+ yield CostEvent(
488
+ seq=0,
489
+ prompt_tokens=prompt_tokens,
490
+ completion_tokens=completion_tokens,
491
+ total_tokens=prompt_tokens + completion_tokens,
492
+ usd=cost_usd,
493
+ )
494
+
495
+ def list_capabilities(self) -> Capabilities:
496
+ return Capabilities(
497
+ streaming=True, tool_calling=True,
498
+ max_context=int(self.config.params.get("max_context", 200_000)),
499
+ reasoning=True, cost_known=True,
500
+ notes="Official Claude Code CLI (stream-json).",
501
+ )
502
+
503
+ def estimate_cost(self, usage: Usage) -> Cost | None:
504
+ p = self.config.params
505
+ price_in = float(p.get("price_in", _DEFAULT_PRICE_IN))
506
+ price_out = float(p.get("price_out", _DEFAULT_PRICE_OUT))
507
+ return Cost(
508
+ prompt_tokens=usage.prompt_tokens,
509
+ completion_tokens=usage.completion_tokens,
510
+ total_tokens=usage.prompt_tokens + usage.completion_tokens,
511
+ usd=usage.prompt_tokens * price_in + usage.completion_tokens * price_out,
512
+ )
513
+
514
+
515
+ def _stringify(content: object) -> str:
516
+ if isinstance(content, str):
517
+ return content
518
+ if isinstance(content, list):
519
+ return "\n".join(
520
+ b.get("text", "") if isinstance(b, dict) else str(b) for b in content
521
+ )
522
+ return str(content) if content is not None else ""