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.
- ags_cli-0.1.0.dist-info/METADATA +332 -0
- ags_cli-0.1.0.dist-info/RECORD +156 -0
- ags_cli-0.1.0.dist-info/WHEEL +5 -0
- ags_cli-0.1.0.dist-info/entry_points.txt +2 -0
- ags_cli-0.1.0.dist-info/top_level.txt +2 -0
- cli/__init__.py +0 -0
- cli/__main__.py +4 -0
- cli/client.py +145 -0
- cli/commands/__init__.py +0 -0
- cli/commands/adapter.py +33 -0
- cli/commands/artifact.py +21 -0
- cli/commands/ask.py +212 -0
- cli/commands/chat.py +649 -0
- cli/commands/diff.py +49 -0
- cli/commands/doctor.py +93 -0
- cli/commands/gui.py +45 -0
- cli/commands/init.py +20 -0
- cli/commands/mcp.py +50 -0
- cli/commands/memory.py +65 -0
- cli/commands/model.py +73 -0
- cli/commands/policy.py +52 -0
- cli/commands/project.py +122 -0
- cli/commands/quota.py +107 -0
- cli/commands/run.py +84 -0
- cli/commands/serve.py +171 -0
- cli/commands/service.py +236 -0
- cli/commands/session.py +219 -0
- cli/commands/stats.py +96 -0
- cli/commands/status.py +36 -0
- cli/commands/task.py +37 -0
- cli/commands/usage.py +86 -0
- cli/local.py +84 -0
- cli/main.py +149 -0
- harness/__init__.py +4 -0
- harness/adapters/__init__.py +26 -0
- harness/adapters/antigravity.py +301 -0
- harness/adapters/claude_code.py +522 -0
- harness/adapters/local_openai.py +534 -0
- harness/adapters/mock.py +112 -0
- harness/adapters/probe.py +65 -0
- harness/adapters/registry.py +56 -0
- harness/adapters/warm_pool.py +255 -0
- harness/api/__init__.py +0 -0
- harness/api/router.py +38 -0
- harness/api/v1/__init__.py +0 -0
- harness/api/v1/adapters.py +105 -0
- harness/api/v1/approvals.py +173 -0
- harness/api/v1/artifacts.py +44 -0
- harness/api/v1/attachments.py +48 -0
- harness/api/v1/doctor.py +22 -0
- harness/api/v1/health.py +37 -0
- harness/api/v1/mcp.py +131 -0
- harness/api/v1/memory.py +80 -0
- harness/api/v1/policies.py +49 -0
- harness/api/v1/project.py +302 -0
- harness/api/v1/runs.py +98 -0
- harness/api/v1/sessions.py +248 -0
- harness/api/v1/stream.py +110 -0
- harness/api/v1/tasks.py +30 -0
- harness/api/v1/usage.py +58 -0
- harness/bootstrap.py +216 -0
- harness/db.py +164 -0
- harness/deps.py +58 -0
- harness/eventbus/__init__.py +20 -0
- harness/eventbus/inprocess_bus.py +85 -0
- harness/main.py +144 -0
- harness/mcp/__init__.py +22 -0
- harness/mcp/client.py +139 -0
- harness/mcp/config_gen.py +77 -0
- harness/mcp/registry.py +156 -0
- harness/mcp/tools.py +81 -0
- harness/memory/__init__.py +13 -0
- harness/memory/context_budget.py +116 -0
- harness/memory/embed.py +217 -0
- harness/memory/extract.py +74 -0
- harness/memory/ingest.py +106 -0
- harness/memory/namespaces.py +57 -0
- harness/memory/ranking.py +31 -0
- harness/memory/redact.py +37 -0
- harness/memory/service.py +320 -0
- harness/memory/sqlite_vec_backend.py +266 -0
- harness/memory/summarize.py +68 -0
- harness/models/__init__.py +35 -0
- harness/models/adapter_health.py +27 -0
- harness/models/approval.py +37 -0
- harness/models/artifact.py +35 -0
- harness/models/audit_log.py +33 -0
- harness/models/comparison.py +33 -0
- harness/models/enums.py +146 -0
- harness/models/mcp_server_config.py +49 -0
- harness/models/memory.py +76 -0
- harness/models/policy.py +29 -0
- harness/models/provider_config.py +35 -0
- harness/models/run.py +46 -0
- harness/models/run_event.py +35 -0
- harness/models/session.py +50 -0
- harness/models/task.py +30 -0
- harness/models/types.py +63 -0
- harness/models/workspace.py +27 -0
- harness/observability/__init__.py +4 -0
- harness/observability/audit.py +88 -0
- harness/observability/logging.py +47 -0
- harness/observability/metrics.py +43 -0
- harness/observability/tracing.py +38 -0
- harness/orchestrator/__init__.py +19 -0
- harness/orchestrator/engine.py +821 -0
- harness/orchestrator/handoff.py +33 -0
- harness/orchestrator/lifecycle.py +69 -0
- harness/orchestrator/native_resume.py +93 -0
- harness/orchestrator/policies.py +101 -0
- harness/orchestrator/reconcile.py +39 -0
- harness/orchestrator/run_graph.py +62 -0
- harness/orchestrator/snapshot.py +49 -0
- harness/schemas/__init__.py +1 -0
- harness/schemas/adapter.py +26 -0
- harness/schemas/approval.py +25 -0
- harness/schemas/artifact.py +17 -0
- harness/schemas/common.py +20 -0
- harness/schemas/memory.py +50 -0
- harness/schemas/policy.py +39 -0
- harness/schemas/run.py +116 -0
- harness/schemas/session.py +93 -0
- harness/schemas/task.py +24 -0
- harness/security/__init__.py +5 -0
- harness/security/approval_policy.py +107 -0
- harness/security/auth.py +106 -0
- harness/security/permissions.py +59 -0
- harness/security/risk.py +59 -0
- harness/security/secrets.py +49 -0
- harness/services/__init__.py +1 -0
- harness/services/adapters_health.py +212 -0
- harness/services/approvals.py +84 -0
- harness/services/artifacts.py +78 -0
- harness/services/attachments.py +228 -0
- harness/services/comparison.py +345 -0
- harness/services/doctor.py +156 -0
- harness/services/files.py +287 -0
- harness/services/mcp_servers.py +179 -0
- harness/services/project_git.py +101 -0
- harness/services/retention.py +97 -0
- harness/services/runs.py +155 -0
- harness/services/runs_diff.py +201 -0
- harness/services/sessions.py +242 -0
- harness/services/ssh.py +300 -0
- harness/services/stats.py +132 -0
- harness/services/tasks.py +41 -0
- harness/services/workspaces.py +184 -0
- harness/services/worktrees.py +439 -0
- harness/settings.py +186 -0
- harness/skills/__init__.py +11 -0
- harness/skills/manager.py +141 -0
- harness/tools/__init__.py +1 -0
- harness/tools/fs_tools.py +295 -0
- harness/workers/__init__.py +0 -0
- harness/workers/dispatch.py +26 -0
- 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 ""
|