ai-push-hooks 0.2.0 → 0.3.0
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.
- package/CHANGELOG.md +68 -2
- package/README.md +582 -85
- package/SECURITY.md +96 -13
- package/ai-push-hooks.toml +9 -2
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/ai_push_hooks/artifacts.py +67 -0
- package/src/ai_push_hooks/config.py +560 -21
- package/src/ai_push_hooks/engine.py +114 -6
- package/src/ai_push_hooks/executors/apply.py +73 -34
- package/src/ai_push_hooks/executors/{llm.py → ask.py} +197 -75
- package/src/ai_push_hooks/executors/exec.py +9 -1
- package/src/ai_push_hooks/executors/runner_workflow.py +478 -0
- package/src/ai_push_hooks/executors/runners/__init__.py +78 -0
- package/src/ai_push_hooks/executors/runners/claude.py +286 -0
- package/src/ai_push_hooks/executors/runners/codex.py +254 -0
- package/src/ai_push_hooks/executors/runners/command.py +178 -0
- package/src/ai_push_hooks/executors/runners/contracts.py +597 -0
- package/src/ai_push_hooks/executors/runners/opencode.py +499 -0
- package/src/ai_push_hooks/executors/runners/process.py +403 -0
- package/src/ai_push_hooks/executors/runners/registry.py +104 -0
- package/src/ai_push_hooks/executors/step_commands.py +473 -0
- package/src/ai_push_hooks/plugin_loader.py +398 -0
- package/src/ai_push_hooks/plugins.py +134 -0
- package/src/ai_push_hooks/prompts_builtin.py +9 -2
- package/src/ai_push_hooks/types.py +406 -75
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
"""OpenCode adapter for the internal runner boundary.
|
|
2
|
+
|
|
3
|
+
The compatibility invocation deliberately remains the shipped ``opencode
|
|
4
|
+
run`` shape. This module owns the adapter-level protocol and lifecycle
|
|
5
|
+
semantics; workflow retries and JSON/schema handling remain in orchestration.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import pathlib
|
|
12
|
+
import shutil
|
|
13
|
+
import sys
|
|
14
|
+
import tempfile
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from ..ask import (
|
|
18
|
+
OPENCODE_APPLY_AGENT,
|
|
19
|
+
OPENCODE_READ_ONLY_AGENT,
|
|
20
|
+
_transcript_dir,
|
|
21
|
+
build_opencode_security_config,
|
|
22
|
+
non_agent_opencode_config,
|
|
23
|
+
opencode_isolation_env,
|
|
24
|
+
resolve_opencode_executable,
|
|
25
|
+
sanitize_filename_component,
|
|
26
|
+
validate_opencode_attachments,
|
|
27
|
+
)
|
|
28
|
+
from ...paths import resolve_contained_path, write_text_no_follow
|
|
29
|
+
from ...paths import ensure_private_directory
|
|
30
|
+
from .process import ProcessResult
|
|
31
|
+
from .contracts import (
|
|
32
|
+
RunnerCapabilities,
|
|
33
|
+
RunnerContractError,
|
|
34
|
+
RunnerError,
|
|
35
|
+
RunnerMissingOutputError,
|
|
36
|
+
RunnerProtocolError,
|
|
37
|
+
RunnerRequest,
|
|
38
|
+
RunnerResult,
|
|
39
|
+
SessionMetadata,
|
|
40
|
+
bounded_diagnostic,
|
|
41
|
+
require_final_text,
|
|
42
|
+
require_zero_exit,
|
|
43
|
+
)
|
|
44
|
+
from .process import run_process
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class OpenCodeRunner:
|
|
48
|
+
"""Run one isolated OpenCode invocation and optionally finalize its session."""
|
|
49
|
+
|
|
50
|
+
capabilities = RunnerCapabilities(
|
|
51
|
+
supports_resume=True,
|
|
52
|
+
supports_finalize=True,
|
|
53
|
+
supports_transcript=True,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def _context(self, request: RunnerRequest) -> Any:
|
|
57
|
+
context = request.integration_context
|
|
58
|
+
if context is None:
|
|
59
|
+
raise RunnerContractError(
|
|
60
|
+
"OpenCode runner requires an integration context for executable and isolation policy"
|
|
61
|
+
)
|
|
62
|
+
return context
|
|
63
|
+
|
|
64
|
+
@staticmethod
|
|
65
|
+
def _parse_output(raw: str, request: RunnerRequest) -> tuple[str | None, str]:
|
|
66
|
+
session_id: str | None = None
|
|
67
|
+
text_parts: list[str] = []
|
|
68
|
+
|
|
69
|
+
def protocol_failure(message: str, *, missing: bool = False) -> RunnerProtocolError:
|
|
70
|
+
error: RunnerProtocolError
|
|
71
|
+
if missing:
|
|
72
|
+
error = RunnerMissingOutputError(message)
|
|
73
|
+
else:
|
|
74
|
+
error = RunnerProtocolError(message)
|
|
75
|
+
# A session may have been announced before a later malformed or
|
|
76
|
+
# error event. Keep that identity on every protocol failure so
|
|
77
|
+
# the orchestrator can still finalize it.
|
|
78
|
+
known_session = session_id
|
|
79
|
+
if known_session is None and request.resume_session:
|
|
80
|
+
known_session = request.session_id
|
|
81
|
+
setattr(error, "session_id", known_session)
|
|
82
|
+
return error
|
|
83
|
+
|
|
84
|
+
for line in raw.splitlines():
|
|
85
|
+
if not line.strip():
|
|
86
|
+
continue
|
|
87
|
+
try:
|
|
88
|
+
event = json.loads(line)
|
|
89
|
+
except json.JSONDecodeError as exc:
|
|
90
|
+
raise protocol_failure("OpenCode emitted malformed JSONL output") from exc
|
|
91
|
+
if not isinstance(event, dict):
|
|
92
|
+
raise protocol_failure("OpenCode emitted a non-object JSONL event")
|
|
93
|
+
|
|
94
|
+
event_type = event.get("type")
|
|
95
|
+
|
|
96
|
+
for key in ("sessionID", "session_id"):
|
|
97
|
+
value = event.get(key)
|
|
98
|
+
if session_id is None and isinstance(value, str) and value.strip():
|
|
99
|
+
session_id = value.strip()
|
|
100
|
+
|
|
101
|
+
if event_type in {"error", "fatal", "session.error"} or (
|
|
102
|
+
isinstance(event.get("error"), (str, dict, list))
|
|
103
|
+
and event_type not in {"text", "step_finish"}
|
|
104
|
+
):
|
|
105
|
+
# Do not serialize the provider event: OpenCode may echo the
|
|
106
|
+
# instruction, artifact bodies, or credential-shaped data.
|
|
107
|
+
raise protocol_failure("OpenCode reported an error event")
|
|
108
|
+
|
|
109
|
+
if event_type != "text":
|
|
110
|
+
# New OpenCode event kinds are additive unless they are an
|
|
111
|
+
# explicit error. Only text parts form the final response.
|
|
112
|
+
continue
|
|
113
|
+
part = event.get("part")
|
|
114
|
+
if not isinstance(part, dict):
|
|
115
|
+
raise protocol_failure("OpenCode text event has no text part")
|
|
116
|
+
text = part.get("text")
|
|
117
|
+
if not isinstance(text, str):
|
|
118
|
+
raise protocol_failure("OpenCode text event has an invalid text value")
|
|
119
|
+
text_parts.append(text)
|
|
120
|
+
|
|
121
|
+
final_text = "\n".join(text_parts).strip()
|
|
122
|
+
if not final_text:
|
|
123
|
+
raise protocol_failure("OpenCode produced no final response", missing=True)
|
|
124
|
+
return session_id, final_text
|
|
125
|
+
|
|
126
|
+
@staticmethod
|
|
127
|
+
def _session_id_from_partial_output(raw: str) -> str | None:
|
|
128
|
+
"""Recover only a complete session identifier from a bounded prefix."""
|
|
129
|
+
|
|
130
|
+
for line in raw.splitlines():
|
|
131
|
+
try:
|
|
132
|
+
event = json.loads(line)
|
|
133
|
+
except json.JSONDecodeError:
|
|
134
|
+
continue
|
|
135
|
+
if not isinstance(event, dict):
|
|
136
|
+
continue
|
|
137
|
+
for key in ("sessionID", "session_id"):
|
|
138
|
+
value = event.get(key)
|
|
139
|
+
if isinstance(value, str) and value.strip():
|
|
140
|
+
return value.strip()
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
@staticmethod
|
|
144
|
+
def _attachment_name(index: int, logical_name: str) -> str:
|
|
145
|
+
# The order prefix prevents collisions after sanitizing path-like
|
|
146
|
+
# logical names while retaining a useful basename for CLI diagnostics.
|
|
147
|
+
basename = pathlib.PurePath(logical_name.replace("\\", "/")).name
|
|
148
|
+
safe_basename = sanitize_filename_component(basename)
|
|
149
|
+
if safe_basename in {"", ".", ".."}:
|
|
150
|
+
safe_basename = "artifact"
|
|
151
|
+
return f"{index:04d}-{safe_basename}"
|
|
152
|
+
|
|
153
|
+
def _materialize_attachments(
|
|
154
|
+
self,
|
|
155
|
+
context: Any,
|
|
156
|
+
request: RunnerRequest,
|
|
157
|
+
) -> tuple[list[pathlib.Path], pathlib.Path | None]:
|
|
158
|
+
original_paths = [artifact.path for artifact in request.artifacts]
|
|
159
|
+
# Validate source ownership and symlink traversal before making any
|
|
160
|
+
# copy. The source is then never reopened: its logical snapshot is
|
|
161
|
+
# the only content sent through OpenCode's native --file transport.
|
|
162
|
+
validate_opencode_attachments(
|
|
163
|
+
context,
|
|
164
|
+
[path for path in original_paths if path is not None],
|
|
165
|
+
)
|
|
166
|
+
if not request.artifacts:
|
|
167
|
+
return [], None
|
|
168
|
+
|
|
169
|
+
run_root = ensure_private_directory(context.run_dir.resolve(strict=True))
|
|
170
|
+
attachment_dir = pathlib.Path(
|
|
171
|
+
tempfile.mkdtemp(prefix="opencode-attachments-", dir=str(run_root))
|
|
172
|
+
)
|
|
173
|
+
try:
|
|
174
|
+
ensure_private_directory(attachment_dir, private_root=run_root)
|
|
175
|
+
paths: list[pathlib.Path] = []
|
|
176
|
+
for index, artifact in enumerate(request.artifacts, start=1):
|
|
177
|
+
target = resolve_contained_path(
|
|
178
|
+
attachment_dir,
|
|
179
|
+
self._attachment_name(index, artifact.name),
|
|
180
|
+
"OpenCode materialized attachment path",
|
|
181
|
+
)
|
|
182
|
+
write_text_no_follow(target, artifact.content)
|
|
183
|
+
paths.append(target)
|
|
184
|
+
return paths, attachment_dir
|
|
185
|
+
except Exception:
|
|
186
|
+
shutil.rmtree(attachment_dir, ignore_errors=True)
|
|
187
|
+
raise
|
|
188
|
+
|
|
189
|
+
@staticmethod
|
|
190
|
+
def _cleanup_attachments(
|
|
191
|
+
attachment_dir: pathlib.Path | None,
|
|
192
|
+
*,
|
|
193
|
+
session_id: str | None = None,
|
|
194
|
+
) -> None:
|
|
195
|
+
if attachment_dir is None:
|
|
196
|
+
return
|
|
197
|
+
# Capture the active exception before entering the cleanup try block;
|
|
198
|
+
# sys.exc_info() inside ``except OSError`` refers to the cleanup error,
|
|
199
|
+
# not the invocation error being unwound.
|
|
200
|
+
unwinding_error = sys.exc_info()[1]
|
|
201
|
+
try:
|
|
202
|
+
shutil.rmtree(attachment_dir)
|
|
203
|
+
except FileNotFoundError:
|
|
204
|
+
return
|
|
205
|
+
except OSError as exc:
|
|
206
|
+
if unwinding_error is not None:
|
|
207
|
+
if session_id and not getattr(unwinding_error, "session_id", None):
|
|
208
|
+
setattr(unwinding_error, "session_id", session_id)
|
|
209
|
+
return
|
|
210
|
+
error = RunnerError("OpenCode attachment cleanup failed")
|
|
211
|
+
if session_id:
|
|
212
|
+
setattr(error, "session_id", session_id)
|
|
213
|
+
raise error from exc
|
|
214
|
+
|
|
215
|
+
def _argv(
|
|
216
|
+
self,
|
|
217
|
+
request: RunnerRequest,
|
|
218
|
+
context: Any,
|
|
219
|
+
executable: str,
|
|
220
|
+
attachments: list[pathlib.Path],
|
|
221
|
+
*,
|
|
222
|
+
prompt: str,
|
|
223
|
+
) -> list[str]:
|
|
224
|
+
agent = OPENCODE_APPLY_AGENT if request.mode == "apply" else OPENCODE_READ_ONLY_AGENT
|
|
225
|
+
config = getattr(getattr(context, "config", None), "llm", None)
|
|
226
|
+
title_prefix = getattr(config, "session_title_prefix", "ai-push-hooks")
|
|
227
|
+
argv = [
|
|
228
|
+
executable,
|
|
229
|
+
"run",
|
|
230
|
+
"--agent",
|
|
231
|
+
agent,
|
|
232
|
+
"--pure",
|
|
233
|
+
"--format",
|
|
234
|
+
"json",
|
|
235
|
+
]
|
|
236
|
+
if request.model:
|
|
237
|
+
argv.extend(["--model", request.model])
|
|
238
|
+
if request.variant:
|
|
239
|
+
argv.extend(["--variant", request.variant])
|
|
240
|
+
if request.resume_session and request.session_id:
|
|
241
|
+
argv.extend(["--session", request.session_id])
|
|
242
|
+
else:
|
|
243
|
+
argv.extend(
|
|
244
|
+
[
|
|
245
|
+
"--title",
|
|
246
|
+
f"{title_prefix} {context.run_id} {request.stage}",
|
|
247
|
+
]
|
|
248
|
+
)
|
|
249
|
+
for path in attachments:
|
|
250
|
+
argv.extend(["--file", str(path)])
|
|
251
|
+
argv.extend(["--", prompt])
|
|
252
|
+
return argv
|
|
253
|
+
|
|
254
|
+
def run(self, request: RunnerRequest) -> RunnerResult:
|
|
255
|
+
if request.runner_type != "opencode":
|
|
256
|
+
raise RunnerContractError(
|
|
257
|
+
f"OpenCode runner cannot handle runner type {request.runner_type!r}"
|
|
258
|
+
)
|
|
259
|
+
context = self._context(request)
|
|
260
|
+
if request.mode == "apply" and not request.allow_paths:
|
|
261
|
+
raise RunnerContractError("OpenCode apply requests require allow_paths")
|
|
262
|
+
if request.mode == "apply" and request.project_access == "artifacts":
|
|
263
|
+
# Apply always runs in a host-created staging projection, even for
|
|
264
|
+
# the compatibility profile.
|
|
265
|
+
working_directory = request.cwd
|
|
266
|
+
elif request.project_access == "project":
|
|
267
|
+
working_directory = request.cwd
|
|
268
|
+
else:
|
|
269
|
+
working_directory = None
|
|
270
|
+
|
|
271
|
+
if working_directory is not None and not working_directory.is_dir():
|
|
272
|
+
raise RunnerContractError("OpenCode request cwd must be an existing directory")
|
|
273
|
+
|
|
274
|
+
agent = "apply" if request.mode == "apply" else "read-only"
|
|
275
|
+
_agent_name, security_config = build_opencode_security_config(
|
|
276
|
+
agent,
|
|
277
|
+
request.allow_paths,
|
|
278
|
+
non_vcs_working_directory=(
|
|
279
|
+
working_directory if request.mode == "apply" else None
|
|
280
|
+
),
|
|
281
|
+
project_read_root=(
|
|
282
|
+
working_directory if request.project_access == "project" else None
|
|
283
|
+
),
|
|
284
|
+
)
|
|
285
|
+
executable = getattr(context, "opencode_executable", None) or resolve_opencode_executable()
|
|
286
|
+
isolated_env = opencode_isolation_env(context, security_config, request.stage)
|
|
287
|
+
attachment_dir: pathlib.Path | None = None
|
|
288
|
+
session_id: str | None = None
|
|
289
|
+
try:
|
|
290
|
+
attachments, attachment_dir = self._materialize_attachments(context, request)
|
|
291
|
+
# Every logical artifact is materialized, including pathless
|
|
292
|
+
# contract artifacts. Therefore native attachments carry the
|
|
293
|
+
# complete snapshot and the instruction is never duplicated in a
|
|
294
|
+
# prompt packet.
|
|
295
|
+
argv = self._argv(
|
|
296
|
+
request,
|
|
297
|
+
context,
|
|
298
|
+
executable,
|
|
299
|
+
attachments,
|
|
300
|
+
prompt=request.instruction,
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
def invoke(cwd: pathlib.Path) -> Any:
|
|
304
|
+
completed = run_process(
|
|
305
|
+
argv,
|
|
306
|
+
cwd=cwd,
|
|
307
|
+
timeout_seconds=request.timeout_seconds,
|
|
308
|
+
env=isolated_env,
|
|
309
|
+
)
|
|
310
|
+
if getattr(completed, "stdout_truncated", False) or getattr(
|
|
311
|
+
completed, "stderr_truncated", False
|
|
312
|
+
):
|
|
313
|
+
error = RunnerProtocolError("OpenCode process output was truncated")
|
|
314
|
+
setattr(
|
|
315
|
+
error,
|
|
316
|
+
"session_id",
|
|
317
|
+
self._session_id_from_partial_output(completed.stdout)
|
|
318
|
+
or (request.session_id if request.resume_session else None),
|
|
319
|
+
)
|
|
320
|
+
raise error
|
|
321
|
+
return completed
|
|
322
|
+
|
|
323
|
+
try:
|
|
324
|
+
if working_directory is None:
|
|
325
|
+
with tempfile.TemporaryDirectory(prefix="ai-push-hooks-readonly-") as directory:
|
|
326
|
+
completed = invoke(pathlib.Path(directory).resolve(strict=True))
|
|
327
|
+
else:
|
|
328
|
+
completed = invoke(working_directory.resolve(strict=True))
|
|
329
|
+
except (RunnerError,) as error:
|
|
330
|
+
process_result = getattr(error, "_process_result", None)
|
|
331
|
+
partial_stdout = (
|
|
332
|
+
process_result.stdout if isinstance(process_result, ProcessResult) else ""
|
|
333
|
+
)
|
|
334
|
+
setattr(
|
|
335
|
+
error,
|
|
336
|
+
"session_id",
|
|
337
|
+
self._session_id_from_partial_output(partial_stdout)
|
|
338
|
+
or getattr(error, "session_id", None)
|
|
339
|
+
or (request.session_id if request.resume_session else None),
|
|
340
|
+
)
|
|
341
|
+
raise
|
|
342
|
+
|
|
343
|
+
result = RunnerResult(
|
|
344
|
+
final_text="",
|
|
345
|
+
returncode=completed.returncode,
|
|
346
|
+
stdout=completed.stdout,
|
|
347
|
+
stderr=completed.stderr,
|
|
348
|
+
)
|
|
349
|
+
# Check the process status before parsing. A failed child may emit
|
|
350
|
+
# a partial or malformed stream; the process failure is the
|
|
351
|
+
# truthful, normalized terminal diagnosis.
|
|
352
|
+
try:
|
|
353
|
+
require_zero_exit(request, result, env=isolated_env)
|
|
354
|
+
except RunnerError as error:
|
|
355
|
+
setattr(
|
|
356
|
+
error,
|
|
357
|
+
"session_id",
|
|
358
|
+
self._session_id_from_partial_output(completed.stdout)
|
|
359
|
+
or (request.session_id if request.resume_session else None),
|
|
360
|
+
)
|
|
361
|
+
raise
|
|
362
|
+
session_id, final_text = self._parse_output(completed.stdout, request)
|
|
363
|
+
if request.resume_session and session_id is None:
|
|
364
|
+
session_id = request.session_id
|
|
365
|
+
return RunnerResult(
|
|
366
|
+
final_text=require_final_text(final_text, mode=request.mode),
|
|
367
|
+
returncode=completed.returncode,
|
|
368
|
+
stdout=completed.stdout,
|
|
369
|
+
stderr=completed.stderr,
|
|
370
|
+
session=(
|
|
371
|
+
SessionMetadata(session_id=session_id, state="persisted", resumable=True)
|
|
372
|
+
if session_id
|
|
373
|
+
else SessionMetadata()
|
|
374
|
+
),
|
|
375
|
+
)
|
|
376
|
+
finally:
|
|
377
|
+
active_error = sys.exc_info()[1]
|
|
378
|
+
cleanup_session_id = session_id or getattr(active_error, "session_id", None)
|
|
379
|
+
self._cleanup_attachments(
|
|
380
|
+
attachment_dir,
|
|
381
|
+
session_id=(
|
|
382
|
+
cleanup_session_id if isinstance(cleanup_session_id, str) else None
|
|
383
|
+
),
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
def _lifecycle_process(
|
|
387
|
+
self,
|
|
388
|
+
request: RunnerRequest,
|
|
389
|
+
context: Any,
|
|
390
|
+
argv: list[str],
|
|
391
|
+
action: str,
|
|
392
|
+
) -> Any:
|
|
393
|
+
# Export/delete must use the original stage isolation root. Using a
|
|
394
|
+
# different action-specific root makes retained sessions invisible to
|
|
395
|
+
# OpenCode because its HOME/XDG state is isolated per stage.
|
|
396
|
+
env = opencode_isolation_env(
|
|
397
|
+
context,
|
|
398
|
+
non_agent_opencode_config(),
|
|
399
|
+
request.stage,
|
|
400
|
+
)
|
|
401
|
+
with tempfile.TemporaryDirectory(prefix=f"ai-push-hooks-session-{action}-") as directory:
|
|
402
|
+
return run_process(
|
|
403
|
+
argv,
|
|
404
|
+
cwd=pathlib.Path(directory).resolve(strict=True),
|
|
405
|
+
timeout_seconds=request.timeout_seconds,
|
|
406
|
+
env=env,
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
def finalize(self, request: RunnerRequest, result: RunnerResult) -> RunnerResult:
|
|
410
|
+
session = result.session
|
|
411
|
+
if session is None or not session.session_id:
|
|
412
|
+
return result
|
|
413
|
+
context = self._context(request)
|
|
414
|
+
session_id = session.session_id
|
|
415
|
+
transcript_path: pathlib.Path | None = None
|
|
416
|
+
|
|
417
|
+
if getattr(context.config.logging, "capture_llm_transcript", False):
|
|
418
|
+
transcript_dir = _transcript_dir(context)
|
|
419
|
+
if transcript_dir is not None:
|
|
420
|
+
transcript_path = resolve_contained_path(
|
|
421
|
+
transcript_dir,
|
|
422
|
+
(
|
|
423
|
+
f"{sanitize_filename_component(context.run_id)}-"
|
|
424
|
+
f"{sanitize_filename_component(request.stage)}-"
|
|
425
|
+
f"{sanitize_filename_component(session_id)}.json"
|
|
426
|
+
),
|
|
427
|
+
"OpenCode transcript path",
|
|
428
|
+
)
|
|
429
|
+
try:
|
|
430
|
+
executable = getattr(context, "opencode_executable", None) or resolve_opencode_executable()
|
|
431
|
+
exported = self._lifecycle_process(
|
|
432
|
+
request,
|
|
433
|
+
context,
|
|
434
|
+
[executable, "export", session_id, "--pure"],
|
|
435
|
+
"export",
|
|
436
|
+
)
|
|
437
|
+
if (
|
|
438
|
+
exported.returncode == 0
|
|
439
|
+
and not getattr(exported, "stdout_truncated", False)
|
|
440
|
+
and not getattr(exported, "stderr_truncated", False)
|
|
441
|
+
and exported.stdout.strip()
|
|
442
|
+
):
|
|
443
|
+
write_text_no_follow(transcript_path, exported.stdout.strip() + "\n")
|
|
444
|
+
else:
|
|
445
|
+
transcript_path = None
|
|
446
|
+
self._warn_export(context, request, session_id, "export returned no transcript")
|
|
447
|
+
except Exception as exc: # noqa: BLE001
|
|
448
|
+
transcript_path = None
|
|
449
|
+
self._warn_export(context, request, session_id, type(exc).__name__)
|
|
450
|
+
|
|
451
|
+
deleted = False
|
|
452
|
+
if getattr(context.config.llm, "delete_session_after_run", False):
|
|
453
|
+
try:
|
|
454
|
+
executable = getattr(context, "opencode_executable", None) or resolve_opencode_executable()
|
|
455
|
+
deleted_result = self._lifecycle_process(
|
|
456
|
+
request,
|
|
457
|
+
context,
|
|
458
|
+
[executable, "session", "delete", session_id, "--pure"],
|
|
459
|
+
"delete",
|
|
460
|
+
)
|
|
461
|
+
deleted = deleted_result.returncode == 0
|
|
462
|
+
if not deleted:
|
|
463
|
+
self._warn_export(context, request, session_id, "session deletion failed")
|
|
464
|
+
except Exception as exc: # noqa: BLE001
|
|
465
|
+
self._warn_export(context, request, session_id, f"delete {type(exc).__name__}")
|
|
466
|
+
|
|
467
|
+
state = "deleted" if deleted else "persisted"
|
|
468
|
+
finalized_session = SessionMetadata(
|
|
469
|
+
session_id=session_id,
|
|
470
|
+
state=state,
|
|
471
|
+
resumable=not deleted,
|
|
472
|
+
transcript=str(transcript_path) if transcript_path is not None else None,
|
|
473
|
+
)
|
|
474
|
+
return RunnerResult(
|
|
475
|
+
final_text=result.final_text,
|
|
476
|
+
returncode=result.returncode,
|
|
477
|
+
stdout=result.stdout,
|
|
478
|
+
stderr=result.stderr,
|
|
479
|
+
session=finalized_session,
|
|
480
|
+
transcript=result.transcript,
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
@staticmethod
|
|
484
|
+
def _warn_export(context: Any, request: RunnerRequest, session_id: str, reason: str) -> None:
|
|
485
|
+
logger = getattr(context, "logger", None)
|
|
486
|
+
if logger is not None:
|
|
487
|
+
logger.warn(
|
|
488
|
+
"llm.transcript_export_failed",
|
|
489
|
+
"Could not capture or delete the OpenCode session cleanly.",
|
|
490
|
+
stage_name=request.stage,
|
|
491
|
+
session_id=session_id,
|
|
492
|
+
reason=bounded_diagnostic(reason, max_chars=120),
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def create_runner() -> OpenCodeRunner:
|
|
497
|
+
"""Registry factory for the built-in OpenCode adapter."""
|
|
498
|
+
|
|
499
|
+
return OpenCodeRunner()
|