ai-push-hooks 0.3.0 → 0.3.1

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.
@@ -54,15 +54,15 @@ def _safe_input_artifacts(
54
54
  ) -> tuple[RunnerArtifact, ...]:
55
55
  """Build the ordered logical artifact list shared by every adapter.
56
56
 
57
- ``validate_opencode_attachments`` is retained as the hook-owned artifact
58
- boundary used by the shipped implementation. The logical names are the
57
+ ``validate_hook_owned_artifacts`` is the hook-owned artifact boundary used
58
+ before adapter dispatch. The logical names are the
59
59
  configured input references, rather than filesystem basenames, so command,
60
60
  Codex, Claude, and OpenCode receive the same ordered context.
61
61
  """
62
62
 
63
- from .ask import validate_opencode_attachments
63
+ from .runners.opencode_support import validate_hook_owned_artifacts
64
64
 
65
- validated = validate_opencode_attachments(context, input_paths)
65
+ validated = validate_hook_owned_artifacts(context, input_paths)
66
66
  if len(validated) != len(step.inputs):
67
67
  raise HookError(
68
68
  f"Runner input count does not match configured inputs for step `{step.id}`"
@@ -1,8 +1,8 @@
1
1
  """OpenCode adapter for the internal runner boundary.
2
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.
3
+ The adapter deliberately retains the shipped ``opencode run`` shape. This
4
+ module owns the adapter-level protocol and lifecycle semantics; workflow
5
+ retries and JSON/schema handling remain in orchestration.
6
6
  """
7
7
 
8
8
  from __future__ import annotations
@@ -14,7 +14,7 @@ import sys
14
14
  import tempfile
15
15
  from typing import Any
16
16
 
17
- from ..ask import (
17
+ from .opencode_support import (
18
18
  OPENCODE_APPLY_AGENT,
19
19
  OPENCODE_READ_ONLY_AGENT,
20
20
  _transcript_dir,
@@ -23,10 +23,9 @@ from ..ask import (
23
23
  opencode_isolation_env,
24
24
  resolve_opencode_executable,
25
25
  sanitize_filename_component,
26
- validate_opencode_attachments,
26
+ validate_hook_owned_artifacts,
27
27
  )
28
- from ...paths import resolve_contained_path, write_text_no_follow
29
- from ...paths import ensure_private_directory
28
+ from ...paths import ensure_private_directory, resolve_contained_path, write_text_no_follow
30
29
  from .process import ProcessResult
31
30
  from .contracts import (
32
31
  RunnerCapabilities,
@@ -159,7 +158,7 @@ class OpenCodeRunner:
159
158
  # Validate source ownership and symlink traversal before making any
160
159
  # copy. The source is then never reopened: its logical snapshot is
161
160
  # the only content sent through OpenCode's native --file transport.
162
- validate_opencode_attachments(
161
+ validate_hook_owned_artifacts(
163
162
  context,
164
163
  [path for path in original_paths if path is not None],
165
164
  )
@@ -251,6 +250,27 @@ class OpenCodeRunner:
251
250
  argv.extend(["--", prompt])
252
251
  return argv
253
252
 
253
+ @staticmethod
254
+ def _require_staging_directory(context: Any, working_directory: pathlib.Path) -> None:
255
+ """Reject repository worktrees as apply targets.
256
+
257
+ The apply workflow supplies a disposable, non-VCS staging projection.
258
+ A runner request can identify the repository root and the selected cwd,
259
+ but it cannot turn an arbitrary host directory into a sandbox. Refuse
260
+ the known unsafe cases at this boundary instead of granting edit
261
+ permissions to the checked-out repository.
262
+ """
263
+
264
+ repository_root = context.repo_root.resolve(strict=True)
265
+ if working_directory == repository_root or working_directory.is_relative_to(repository_root):
266
+ raise RunnerContractError(
267
+ "OpenCode apply requests require an isolated staging directory"
268
+ )
269
+ if any((parent / ".git").exists() for parent in (working_directory, *working_directory.parents)):
270
+ raise RunnerContractError(
271
+ "OpenCode apply requests require a non-VCS isolated staging directory"
272
+ )
273
+
254
274
  def run(self, request: RunnerRequest) -> RunnerResult:
255
275
  if request.runner_type != "opencode":
256
276
  raise RunnerContractError(
@@ -268,8 +288,17 @@ class OpenCodeRunner:
268
288
  else:
269
289
  working_directory = None
270
290
 
271
- if working_directory is not None and not working_directory.is_dir():
272
- raise RunnerContractError("OpenCode request cwd must be an existing directory")
291
+ if working_directory is not None:
292
+ try:
293
+ working_directory = working_directory.resolve(strict=True)
294
+ except (OSError, RuntimeError) as exc:
295
+ raise RunnerContractError(
296
+ "OpenCode request cwd must be an existing directory"
297
+ ) from exc
298
+ if not working_directory.is_dir():
299
+ raise RunnerContractError("OpenCode request cwd must be an existing directory")
300
+ if request.mode == "apply":
301
+ self._require_staging_directory(context, working_directory)
273
302
 
274
303
  agent = "apply" if request.mode == "apply" else "read-only"
275
304
  _agent_name, security_config = build_opencode_security_config(
@@ -325,7 +354,7 @@ class OpenCodeRunner:
325
354
  with tempfile.TemporaryDirectory(prefix="ai-push-hooks-readonly-") as directory:
326
355
  completed = invoke(pathlib.Path(directory).resolve(strict=True))
327
356
  else:
328
- completed = invoke(working_directory.resolve(strict=True))
357
+ completed = invoke(working_directory)
329
358
  except (RunnerError,) as error:
330
359
  process_result = getattr(error, "_process_result", None)
331
360
  partial_stdout = (
@@ -0,0 +1,276 @@
1
+ """Shared OpenCode isolation and hook-owned artifact policy helpers.
2
+
3
+ This module contains only the OpenCode-specific filesystem, environment, and
4
+ permission policy used by the active adapter. Workflow orchestration remains
5
+ runner-neutral and the adapter owns invocation and session lifecycle.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import pathlib
13
+ import re
14
+ import shutil
15
+ from typing import Any
16
+
17
+ from ...paths import (
18
+ ensure_private_directory,
19
+ is_path_within,
20
+ path_has_symlink,
21
+ resolve_contained_path,
22
+ )
23
+ from ...git_utils import ensure_dir, resolve_storage_path
24
+ from ...types import HookError, RuntimeContext
25
+
26
+
27
+ OPENCODE_READ_ONLY_AGENT = "ai-push-hooks-readonly"
28
+ OPENCODE_APPLY_AGENT = "ai-push-hooks-apply"
29
+ PROVIDER_ENV_PREFIXES = (
30
+ "ANTHROPIC_",
31
+ "AWS_",
32
+ "AZURE_",
33
+ "COHERE_",
34
+ "DEEPSEEK_",
35
+ "GEMINI_",
36
+ "GOOGLE_",
37
+ "GROQ_",
38
+ "MISTRAL_",
39
+ "OPENAI_",
40
+ "OPENROUTER_",
41
+ "VERTEX_",
42
+ "XAI_",
43
+ )
44
+ SAFE_PROCESS_ENV_NAMES = frozenset(
45
+ {
46
+ "PATH",
47
+ "TMPDIR",
48
+ "TMP",
49
+ "TEMP",
50
+ "LANG",
51
+ "LC_ALL",
52
+ "LC_CTYPE",
53
+ "SYSTEMROOT",
54
+ }
55
+ )
56
+
57
+
58
+ def _actual_xdg_data_home() -> pathlib.Path:
59
+ configured = os.environ.get("XDG_DATA_HOME", "").strip()
60
+ if configured:
61
+ return pathlib.Path(configured).expanduser().resolve(strict=False)
62
+ return (pathlib.Path.home() / ".local" / "share").resolve(strict=False)
63
+
64
+
65
+ def opencode_isolation_env(
66
+ context: RuntimeContext,
67
+ security_config: dict[str, Any],
68
+ stage_name: str,
69
+ ) -> dict[str, str | None]:
70
+ lexical_isolation_root = (
71
+ context.run_dir / "opencode-isolation" / sanitize_filename_component(stage_name)
72
+ )
73
+ if path_has_symlink(context.run_dir, lexical_isolation_root):
74
+ raise HookError(f"OpenCode isolation directory must not traverse a symlink: {stage_name}")
75
+ isolation_root = resolve_contained_path(
76
+ context.run_dir,
77
+ f"opencode-isolation/{sanitize_filename_component(stage_name)}",
78
+ "OpenCode isolation directory",
79
+ )
80
+ home = isolation_root / "home"
81
+ config_home = isolation_root / "config"
82
+ cache_home = isolation_root / "cache"
83
+ state_home = isolation_root / "state"
84
+ ensure_private_directory(isolation_root)
85
+ for path in (home, config_home, cache_home, state_home):
86
+ ensure_private_directory(path, private_root=isolation_root)
87
+
88
+ isolated: dict[str, str | None] = {
89
+ name: value
90
+ for name, value in os.environ.items()
91
+ if name in SAFE_PROCESS_ENV_NAMES or name.startswith(PROVIDER_ENV_PREFIXES)
92
+ }
93
+ isolated.update(
94
+ {
95
+ "HOME": str(home),
96
+ "XDG_CONFIG_HOME": str(config_home),
97
+ "XDG_CACHE_HOME": str(cache_home),
98
+ "XDG_STATE_HOME": str(state_home),
99
+ "XDG_DATA_HOME": str(_actual_xdg_data_home()),
100
+ "OPENCODE_CONFIG_CONTENT": json.dumps(security_config, ensure_ascii=True),
101
+ "OPENCODE_CONFIG_DIR": str(config_home),
102
+ "OPENCODE_PURE": "true",
103
+ "OPENCODE_DISABLE_PROJECT_CONFIG": "true",
104
+ "OPENCODE_DISABLE_EXTERNAL_SKILLS": "true",
105
+ "OPENCODE_DISABLE_CLAUDE_CODE": "true",
106
+ "OPENCODE_DISABLE_CLAUDE_CODE_PROMPT": "true",
107
+ "OPENCODE_DISABLE_CLAUDE_CODE_SKILLS": "true",
108
+ "OPENCODE_DISABLE_LSP_DOWNLOAD": "true",
109
+ "OPENCODE_DISABLE_SHARE": "true",
110
+ "OPENCODE_DISABLE_AUTOUPDATE": "true",
111
+ }
112
+ )
113
+ return isolated
114
+
115
+
116
+ def non_agent_opencode_config() -> dict[str, Any]:
117
+ return {
118
+ "$schema": "https://opencode.ai/config.json",
119
+ "plugin": [],
120
+ "mcp": {},
121
+ "share": "disabled",
122
+ "instructions": [],
123
+ "formatter": False,
124
+ "lsp": False,
125
+ "command": {},
126
+ "permission": {"*": "deny"},
127
+ }
128
+
129
+
130
+ def sanitize_filename_component(value: str) -> str:
131
+ cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip())
132
+ return cleaned.strip("-") or "value"
133
+
134
+
135
+ def resolve_opencode_executable() -> str:
136
+ opencode_path = shutil.which("opencode")
137
+ if opencode_path:
138
+ return opencode_path
139
+ cli_path = shutil.which("opencode-cli")
140
+ if cli_path:
141
+ return cli_path
142
+ raise HookError("opencode is required but not installed")
143
+
144
+
145
+ def _transcript_dir(context: RuntimeContext) -> pathlib.Path | None:
146
+ if not context.config.logging.capture_llm_transcript:
147
+ return None
148
+ return ensure_dir(
149
+ resolve_storage_path(
150
+ context.repo_root,
151
+ context.git_dir,
152
+ context.config.logging.transcript_dir,
153
+ )
154
+ )
155
+
156
+
157
+ def build_opencode_security_config(
158
+ agent_policy: str,
159
+ allow_paths: tuple[str, ...] = (),
160
+ *,
161
+ non_vcs_working_directory: pathlib.Path | None = None,
162
+ project_read_root: pathlib.Path | None = None,
163
+ ) -> tuple[str, dict[str, Any]]:
164
+ permissions: dict[str, Any] = {
165
+ "*": "deny",
166
+ "read": "deny",
167
+ "glob": "deny",
168
+ "grep": "deny",
169
+ "list": "deny",
170
+ "edit": "deny",
171
+ "bash": "deny",
172
+ "task": "deny",
173
+ "external_directory": "deny",
174
+ "webfetch": "deny",
175
+ "websearch": "deny",
176
+ "lsp": "deny",
177
+ "skill": "deny",
178
+ "todowrite": "deny",
179
+ "question": "deny",
180
+ "doom_loop": "deny",
181
+ }
182
+ if agent_policy == "read-only":
183
+ agent_name = OPENCODE_READ_ONLY_AGENT
184
+ description = "Read-only ai-push-hooks analysis agent"
185
+ elif agent_policy == "apply":
186
+ if not allow_paths:
187
+ raise HookError("OpenCode apply agent requires an explicit non-empty allow_paths")
188
+ agent_name = OPENCODE_APPLY_AGENT
189
+ description = "Path-restricted ai-push-hooks apply agent"
190
+ permissions["read"] = "allow"
191
+ permission_patterns: set[str] = set()
192
+ for pattern in allow_paths:
193
+ permission_patterns.add(pattern)
194
+ collapsed = pattern
195
+ while "**/" in collapsed:
196
+ collapsed = collapsed.replace("**/", "", 1)
197
+ permission_patterns.add(collapsed)
198
+ protected_patterns = {".git", ".git/**"}
199
+ if non_vcs_working_directory is not None:
200
+ # OpenCode assigns `/` as the worktree for a directory without VCS
201
+ # metadata, so qualify allowlisted paths with that filesystem root.
202
+ anchor = pathlib.Path(non_vcs_working_directory.anchor)
203
+ prefix = non_vcs_working_directory.relative_to(anchor).as_posix()
204
+ permission_patterns = {
205
+ f"{prefix}/{pattern}" if prefix else pattern
206
+ for pattern in permission_patterns
207
+ }
208
+ protected_patterns = {
209
+ f"{prefix}/{pattern}" if prefix else pattern
210
+ for pattern in protected_patterns
211
+ }
212
+ edit_permissions = {
213
+ "*": "deny",
214
+ **{pattern: "allow" for pattern in sorted(permission_patterns)},
215
+ }
216
+ for pattern in sorted(protected_patterns):
217
+ edit_permissions[pattern] = "deny"
218
+ permissions["edit"] = edit_permissions
219
+ else:
220
+ raise HookError(f"Unsupported OpenCode agent policy: {agent_policy}")
221
+
222
+ if project_read_root is not None:
223
+ root = project_read_root.resolve(strict=False)
224
+ if (root / ".git").exists():
225
+ rooted_permissions: str | dict[str, str] = "allow"
226
+ else:
227
+ anchor = pathlib.Path(root.anchor)
228
+ prefix = root.relative_to(anchor).as_posix()
229
+ rooted_permissions = {
230
+ "*": "deny",
231
+ prefix: "allow",
232
+ f"{prefix}/**": "allow",
233
+ }
234
+ for tool in ("read", "list", "glob", "grep"):
235
+ permissions[tool] = (
236
+ dict(rooted_permissions)
237
+ if isinstance(rooted_permissions, dict)
238
+ else rooted_permissions
239
+ )
240
+
241
+ return agent_name, {
242
+ "$schema": "https://opencode.ai/config.json",
243
+ "plugin": [],
244
+ "mcp": {},
245
+ "share": "disabled",
246
+ "instructions": [],
247
+ "formatter": False,
248
+ "lsp": False,
249
+ "command": {},
250
+ "agent": {
251
+ agent_name: {
252
+ "mode": "primary",
253
+ "description": description,
254
+ "permission": permissions,
255
+ }
256
+ },
257
+ }
258
+
259
+
260
+ def validate_hook_owned_artifacts(
261
+ context: RuntimeContext,
262
+ files: list[pathlib.Path],
263
+ ) -> list[pathlib.Path]:
264
+ run_root = context.run_dir.resolve(strict=True)
265
+ validated: list[pathlib.Path] = []
266
+ for file_path in files:
267
+ lexical_path = pathlib.Path(os.path.abspath(file_path))
268
+ if not is_path_within(lexical_path, run_root):
269
+ raise HookError(f"OpenCode attachment is not a hook-owned artifact: {file_path}")
270
+ if path_has_symlink(run_root, lexical_path):
271
+ raise HookError(f"OpenCode attachment must not traverse a symlink: {file_path}")
272
+ resolved_path = lexical_path.resolve(strict=True)
273
+ if not is_path_within(resolved_path, run_root) or not resolved_path.is_file():
274
+ raise HookError(f"OpenCode attachment must be a regular hook-owned file: {file_path}")
275
+ validated.append(resolved_path)
276
+ return validated
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import math
5
6
  import os
6
7
  import pathlib
7
8
  import signal
@@ -58,6 +59,7 @@ def _read_bounded(
58
59
  output: bytearray,
59
60
  truncated: list[bool],
60
61
  output_lock: threading.Lock,
62
+ output_limit_reached: threading.Event,
61
63
  ) -> None:
62
64
  read = getattr(stream, "read")
63
65
  try:
@@ -74,6 +76,7 @@ def _read_bounded(
74
76
  output.extend(chunk[:remaining])
75
77
  if len(chunk) > remaining:
76
78
  truncated[0] = True
79
+ output_limit_reached.set()
77
80
  finally:
78
81
  _close_pipe(stream)
79
82
 
@@ -213,6 +216,7 @@ def run_process(
213
216
  timeout_seconds: float,
214
217
  env: Mapping[str, str] | None = None,
215
218
  max_output_bytes: int = DEFAULT_MAX_OUTPUT_BYTES,
219
+ max_stderr_bytes: int | None = None,
216
220
  ) -> ProcessResult:
217
221
  """Run an argv vector directly, with bounded concurrent stream capture.
218
222
 
@@ -230,10 +234,26 @@ def run_process(
230
234
  cwd = pathlib.Path(cwd)
231
235
  if not cwd.is_dir():
232
236
  raise RunnerError("runner cwd must be an existing directory")
233
- if timeout_seconds <= 0:
234
- raise RunnerError("runner timeout must be greater than zero")
235
- if max_output_bytes < 0:
237
+ if (
238
+ isinstance(timeout_seconds, bool)
239
+ or not isinstance(timeout_seconds, (int, float))
240
+ or not math.isfinite(timeout_seconds)
241
+ or timeout_seconds <= 0
242
+ ):
243
+ raise RunnerError("runner timeout must be a finite number greater than zero")
244
+ if (
245
+ isinstance(max_output_bytes, bool)
246
+ or not isinstance(max_output_bytes, int)
247
+ or max_output_bytes < 0
248
+ ):
236
249
  raise RunnerError("runner output bound must not be negative")
250
+ stderr_limit = max_output_bytes if max_stderr_bytes is None else max_stderr_bytes
251
+ if (
252
+ isinstance(stderr_limit, bool)
253
+ or not isinstance(stderr_limit, int)
254
+ or stderr_limit < 0
255
+ ):
256
+ raise RunnerError("runner stderr output bound must not be negative")
237
257
  if input_text is not None and input_path is not None:
238
258
  raise RunnerError("runner input_text and input_path are mutually exclusive")
239
259
  input_file = None
@@ -306,14 +326,29 @@ def run_process(
306
326
  stderr_lock = threading.Lock()
307
327
  stdout_truncated = [False]
308
328
  stderr_truncated = [False]
329
+ output_limit_reached = threading.Event()
309
330
  stdout_thread = threading.Thread(
310
331
  target=_read_bounded,
311
- args=(process.stdout, max_output_bytes, stdout, stdout_truncated, stdout_lock),
332
+ args=(
333
+ process.stdout,
334
+ max_output_bytes,
335
+ stdout,
336
+ stdout_truncated,
337
+ stdout_lock,
338
+ output_limit_reached,
339
+ ),
312
340
  daemon=True,
313
341
  )
314
342
  stderr_thread = threading.Thread(
315
343
  target=_read_bounded,
316
- args=(process.stderr, max_output_bytes, stderr, stderr_truncated, stderr_lock),
344
+ args=(
345
+ process.stderr,
346
+ stderr_limit,
347
+ stderr,
348
+ stderr_truncated,
349
+ stderr_lock,
350
+ output_limit_reached,
351
+ ),
317
352
  daemon=True,
318
353
  )
319
354
  stdout_thread.start()
@@ -350,23 +385,49 @@ def run_process(
350
385
 
351
386
  input_thread = threading.Thread(target=write_input, daemon=True)
352
387
  input_thread.start()
388
+ process_exited = threading.Event()
389
+ process_returncode: list[int | None] = [None]
390
+
391
+ def wait_for_process_exit() -> None:
392
+ process_returncode[0] = process.wait()
393
+ process_exited.set()
394
+
395
+ process_wait_thread = threading.Thread(target=wait_for_process_exit, daemon=True)
396
+ process_wait_thread.start()
353
397
  returncode: int | None = None
354
398
  timeout_cause: subprocess.TimeoutExpired | None = None
399
+ output_limit_cause = False
355
400
  try:
356
- returncode = process.wait(timeout=timeout_seconds)
357
- except subprocess.TimeoutExpired as exc:
358
- timeout_cause = exc
401
+ deadline = time.monotonic() + timeout_seconds
402
+ while True:
403
+ remaining = deadline - time.monotonic()
404
+ if remaining <= 0:
405
+ timeout_cause = subprocess.TimeoutExpired(list(argv), timeout_seconds)
406
+ break
407
+ if output_limit_reached.is_set():
408
+ # A short grace period preserves the natural exit status for
409
+ # commands that wrote their final bounded chunk and were
410
+ # already exiting. A still-live child is stopped below
411
+ # rather than allowed to drain discarded output until timeout.
412
+ if process_exited.wait(timeout=min(remaining, 0.05)):
413
+ returncode = process_returncode[0]
414
+ else:
415
+ output_limit_cause = True
416
+ break
417
+ if process_exited.wait(timeout=min(remaining, 0.05)):
418
+ returncode = process_returncode[0]
419
+ break
359
420
  finally:
360
421
  cleanup_deadline = time.monotonic() + PROCESS_CLEANUP_GRACE_SECONDS
361
422
  _stop_process(process, cleanup_deadline)
362
423
  _finish_capture(
363
424
  (process.stdin, process.stdout, process.stderr),
364
- (input_thread, stdout_thread, stderr_thread),
425
+ (input_thread, stdout_thread, stderr_thread, process_wait_thread),
365
426
  cleanup_deadline,
366
427
  )
367
428
 
368
429
  if returncode is None:
369
- returncode = process.poll()
430
+ returncode = process_returncode[0] if process_returncode[0] is not None else process.poll()
370
431
  if timeout_cause is not None:
371
432
  timeout_returncode = returncode if returncode is not None else -getattr(signal, "SIGKILL", 9)
372
433
  process_result = _captured_result(
@@ -393,7 +454,7 @@ def run_process(
393
454
  stdout_lock,
394
455
  stderr_lock,
395
456
  )
396
- if returncode < 0:
457
+ if returncode < 0 and not output_limit_cause:
397
458
  error = RunnerSignalError(
398
459
  "runner process terminated by signal",
399
460
  details=str(-returncode),
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import importlib
6
+ import threading
6
7
  from dataclasses import dataclass
7
8
  from types import ModuleType
8
9
  from typing import Callable, Mapping
@@ -78,6 +79,9 @@ class RunnerRegistry:
78
79
  raise RunnerContractError("runner registry must contain all four known adapter types")
79
80
  self._specs = dict(selected)
80
81
  self._loaded: dict[str, Runner] = {}
82
+ self._load_locks = {
83
+ runner_type: threading.Lock() for runner_type in KNOWN_RUNNER_TYPES
84
+ }
81
85
 
82
86
  @property
83
87
  def known_types(self) -> tuple[str, ...]:
@@ -86,10 +90,19 @@ class RunnerRegistry:
86
90
  def get(self, runner_type: str) -> Runner:
87
91
  if runner_type not in self._specs:
88
92
  raise RunnerContractError(f"unknown runner type: {runner_type!r}")
89
- if runner_type not in self._loaded:
90
- spec = self._specs[runner_type]
91
- self._loaded[runner_type] = spec.load() if isinstance(spec, LazyRunnerSpec) else spec()
92
- return self._loaded[runner_type]
93
+ runner = self._loaded.get(runner_type)
94
+ if runner is not None:
95
+ return runner
96
+ # Initialization is serialized only for the selected adapter. Other
97
+ # runner types may load concurrently, which matters for capability
98
+ # probes such as Claude's --help check.
99
+ with self._load_locks[runner_type]:
100
+ runner = self._loaded.get(runner_type)
101
+ if runner is None:
102
+ spec = self._specs[runner_type]
103
+ runner = spec.load() if isinstance(spec, LazyRunnerSpec) else spec()
104
+ self._loaded[runner_type] = runner
105
+ return runner
93
106
 
94
107
  def __contains__(self, runner_type: object) -> bool:
95
108
  return runner_type in self._specs
@@ -19,7 +19,13 @@ from typing import Any
19
19
 
20
20
  from ..artifacts import ArtifactStore
21
21
  from ..paths import path_is_link_or_reparse
22
- from ..types import HookError, ModuleRuntimeState, RuntimeContext, StepConfig
22
+ from ..types import (
23
+ DEFAULT_STEP_COMMAND_TIMEOUT_SECONDS,
24
+ HookError,
25
+ ModuleRuntimeState,
26
+ RuntimeContext,
27
+ StepConfig,
28
+ )
23
29
  from .runners.contracts import bounded_diagnostic, redact_diagnostic
24
30
  from .runners.process import (
25
31
  DEFAULT_MAX_OUTPUT_BYTES,
@@ -27,7 +33,6 @@ from .runners.process import (
27
33
  run_process,
28
34
  )
29
35
 
30
- DEFAULT_STEP_COMMAND_TIMEOUT_SECONDS = 60
31
36
  STEP_COMMAND_STDOUT_ARTIFACT = "stdout.txt"
32
37
  STEP_COMMAND_STDERR_ARTIFACT = "stderr.txt"
33
38
  STEP_COMMAND_RESULT_ARTIFACT = "result.json"