pilot-workers 0.2.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 (66) hide show
  1. pilot_workers/__init__.py +1 -0
  2. pilot_workers/__main__.py +3 -0
  3. pilot_workers/cli/__init__.py +0 -0
  4. pilot_workers/cli/dispatch.py +577 -0
  5. pilot_workers/cli/install.py +259 -0
  6. pilot_workers/cli/main.py +115 -0
  7. pilot_workers/cli/run.py +191 -0
  8. pilot_workers/credentials.py +102 -0
  9. pilot_workers/data/permissions/README.md +47 -0
  10. pilot_workers/data/permissions/relaxed.yaml +10 -0
  11. pilot_workers/data/permissions/strict.yaml +9 -0
  12. pilot_workers/data/providers/README.md +24 -0
  13. pilot_workers/data/providers/ds.yaml +11 -0
  14. pilot_workers/data/providers/glm.yaml +11 -0
  15. pilot_workers/data/providers/kimi-k3.yaml +11 -0
  16. pilot_workers/data/templates/code.md +28 -0
  17. pilot_workers/data/templates/explore.md +17 -0
  18. pilot_workers/data/templates/review.md +18 -0
  19. pilot_workers/data/templates/test.md +17 -0
  20. pilot_workers/fmt_events.py +218 -0
  21. pilot_workers/integrations/README.md +25 -0
  22. pilot_workers/integrations/claude-host/agents/ds-coder.md +66 -0
  23. pilot_workers/integrations/claude-host/agents/ds-explorer.md +56 -0
  24. pilot_workers/integrations/claude-host/agents/ds-reviewer.md +50 -0
  25. pilot_workers/integrations/claude-host/agents/ds-tester.md +46 -0
  26. pilot_workers/integrations/claude-host/agents/glm-coder.md +66 -0
  27. pilot_workers/integrations/claude-host/agents/glm-explorer.md +56 -0
  28. pilot_workers/integrations/claude-host/agents/glm-reviewer.md +50 -0
  29. pilot_workers/integrations/claude-host/agents/glm-tester.md +46 -0
  30. pilot_workers/integrations/claude-host/agents/kimi-coder.md +66 -0
  31. pilot_workers/integrations/claude-host/agents/kimi-explorer.md +56 -0
  32. pilot_workers/integrations/claude-host/agents/kimi-reviewer.md +50 -0
  33. pilot_workers/integrations/claude-host/agents/kimi-tester.md +50 -0
  34. pilot_workers/integrations/claude-host/commands/glm/code.md +48 -0
  35. pilot_workers/integrations/claude-host/commands/glm/explore.md +38 -0
  36. pilot_workers/integrations/claude-host/commands/glm/review.md +38 -0
  37. pilot_workers/integrations/claude-host/commands/glm/test.md +30 -0
  38. pilot_workers/integrations/claude-host/commands/kimi/code.md +47 -0
  39. pilot_workers/integrations/claude-host/commands/kimi/explore.md +38 -0
  40. pilot_workers/integrations/claude-host/commands/kimi/review.md +38 -0
  41. pilot_workers/integrations/claude-host/commands/kimi/test.md +32 -0
  42. pilot_workers/integrations/codex-host/ds/SKILL.md +15 -0
  43. pilot_workers/integrations/codex-host/ds/openai.yaml +4 -0
  44. pilot_workers/integrations/codex-host/glm/SKILL.md +15 -0
  45. pilot_workers/integrations/codex-host/glm/openai.yaml +4 -0
  46. pilot_workers/integrations/codex-host/kimi/SKILL.md +15 -0
  47. pilot_workers/integrations/codex-host/kimi/openai.yaml +4 -0
  48. pilot_workers/maintain.py +170 -0
  49. pilot_workers/policy.py +295 -0
  50. pilot_workers/prompts/code.md +6 -0
  51. pilot_workers/prompts/common.md +24 -0
  52. pilot_workers/prompts/explore.md +7 -0
  53. pilot_workers/prompts/review.md +5 -0
  54. pilot_workers/prompts/test.md +6 -0
  55. pilot_workers/providers.py +137 -0
  56. pilot_workers/runners/__init__.py +28 -0
  57. pilot_workers/runners/base.py +123 -0
  58. pilot_workers/runners/opencode_runner.py +377 -0
  59. pilot_workers/runtime.py +314 -0
  60. pilot_workers/scripts/install_runtime.sh +51 -0
  61. pilot_workers-0.2.0.dist-info/METADATA +84 -0
  62. pilot_workers-0.2.0.dist-info/RECORD +66 -0
  63. pilot_workers-0.2.0.dist-info/WHEEL +5 -0
  64. pilot_workers-0.2.0.dist-info/entry_points.txt +2 -0
  65. pilot_workers-0.2.0.dist-info/licenses/LICENSE +21 -0
  66. pilot_workers-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,377 @@
1
+ """OpenCode runner adapter — concrete Runner for the opencode-ai CLI.
2
+
3
+ This module is the new home for the OpenCode-specific glue that used to live
4
+ inline in ``cli/run.py`` / ``runtime.py`` / ``cli/dispatch.py`` /
5
+ ``fmt_events.py``. Behaviour is preserved verbatim from those sources.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import subprocess
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from pilot_workers import policy
17
+ from pilot_workers.providers import (
18
+ Provider,
19
+ pilot_home,
20
+ profile_paths,
21
+ profile_root,
22
+ )
23
+ from pilot_workers.runners.base import (
24
+ Runner,
25
+ TokenUsage,
26
+ ToolCall,
27
+ UnifiedEvent,
28
+ )
29
+
30
+
31
+ # Pinned engine version. Single source of truth — install_runtime.sh and
32
+ # everything else reads it from here.
33
+ PINNED_OPENCODE_VERSION = "1.18.4"
34
+
35
+ # Substring in a tool error message that signals a permission-rule denial.
36
+ # Mirrors the check formerly inlined in ``cli/dispatch.py`` (parse_jsonl,
37
+ # tool_use branch).
38
+ _PERMISSION_DENIED_MARK = "rule which prevents"
39
+
40
+ # Tools whose output is a file dump or a plain confirmation — the input path
41
+ # already says everything; echoing the output only adds noise. Mirrors
42
+ # ``fmt_events.SILENT_OUTPUT_TOOLS`` (deleted from there).
43
+ SILENT_OUTPUT_TOOLS = {"read", "edit", "write", "list", "todowrite"}
44
+
45
+ # Renderer input/output limits (mirror fmt_events defaults verbatim).
46
+ _INPUT_LIMIT = 200
47
+
48
+
49
+ class OpenCodeRunner(Runner):
50
+ """Adapter for the opencode-ai CLI (pinned to ``PINNED_OPENCODE_VERSION``)."""
51
+
52
+ name = "opencode"
53
+
54
+ # ------------------------------------------------------------------
55
+ # config / command / env
56
+ # ------------------------------------------------------------------
57
+
58
+ def build_config(
59
+ self, provider: Any, mode: str, permission_profile: str | None = None,
60
+ ) -> dict:
61
+ # Thin pass-through to policy.build_config — single source of truth.
62
+ return policy.build_config(
63
+ provider, mode, permission_profile=permission_profile,
64
+ )
65
+
66
+ def build_command(
67
+ self, binary: Path, provider: Any, mode: str,
68
+ workdir: Path, run_id: str, session: str | None,
69
+ ) -> list[str]:
70
+ # Mirrors cli/run.py:118-128 exactly.
71
+ command = [
72
+ str(binary), "--pure", "run",
73
+ "--model", provider.model,
74
+ "--agent", policy.MODE_TO_AGENT[mode],
75
+ "--format", "json",
76
+ "--thinking",
77
+ "--title", f"pilot-worker-{mode}-{run_id}",
78
+ "--dir", str(workdir),
79
+ ]
80
+ if session:
81
+ command.extend(["--session", session])
82
+ return command
83
+
84
+ def runner_environment(self, provider: Any, config: dict) -> dict[str, str]:
85
+ # Mirrors the OpenCode-only slice of the former runtime.build_environment
86
+ # (runtime.py:47-56). Neutral keys (SAFE_ENV_KEYS, XDG_*, NO_COLOR,
87
+ # CI) are added by the runtime layer, not here.
88
+ paths = profile_paths(provider)
89
+ return {
90
+ "OPENCODE_CONFIG_DIR": str(paths["config"] / "opencode"),
91
+ "OPENCODE_CONFIG_CONTENT": json.dumps(config, separators=(",", ":")),
92
+ "OPENCODE_DISABLE_AUTOUPDATE": "1",
93
+ "OPENCODE_AUTO_SHARE": "false",
94
+ "OPENCODE_DISABLE_DEFAULT_PLUGINS": "1",
95
+ "OPENCODE_DISABLE_LSP_DOWNLOAD": "1",
96
+ "OPENCODE_DISABLE_MODELS_FETCH": "1",
97
+ "OPENCODE_DISABLE_CLAUDE_CODE": "1",
98
+ "OPENCODE_DISABLE_CLAUDE_CODE_PROMPT": "1",
99
+ "OPENCODE_DISABLE_CLAUDE_CODE_SKILLS": "1",
100
+ }
101
+
102
+ def format_task_input(self, task: str, mode: str) -> str:
103
+ # Mirrors cli/run.py:117.
104
+ return f'<worker-task mode="{mode}">\n{task}\n</worker-task>'
105
+
106
+ # ------------------------------------------------------------------
107
+ # event translation
108
+ # ------------------------------------------------------------------
109
+
110
+ def parse_events(self, raw: dict) -> list[UnifiedEvent]:
111
+ """Translate one raw OpenCode event into 0..n UnifiedEvents.
112
+
113
+ Behaviour mirrors the union of the former inline translators:
114
+ - cli/dispatch.parse_jsonl (step/text/tool/error branches)
115
+ - fmt_events tool input/output brief extraction
116
+ - runtime.run_process recursive session-id scan
117
+ """
118
+ events: list[UnifiedEvent] = []
119
+
120
+ # Top-level timestamp → ts (epoch ms). bool excluded.
121
+ ts_value = raw.get("timestamp")
122
+ ts: int | None = None
123
+ if isinstance(ts_value, (int, float)) and not isinstance(ts_value, bool):
124
+ ts = int(ts_value)
125
+
126
+ # Recursive session-id scan: emit one session event if any found.
127
+ # runtime.run_process keeps the LAST id (ids[-1]); do the same here.
128
+ ids = _session_ids(raw)
129
+ if ids:
130
+ events.append(UnifiedEvent(kind="session", ts=ts, session_id=ids[-1]))
131
+
132
+ event_type = raw.get("type")
133
+
134
+ if event_type == "step_finish":
135
+ events.append(UnifiedEvent(
136
+ kind="step",
137
+ ts=ts,
138
+ tokens=_extract_tokens(raw.get("part")),
139
+ ))
140
+ elif event_type == "text":
141
+ events.append(UnifiedEvent(
142
+ kind="text",
143
+ ts=ts,
144
+ text=_part_text(raw.get("part")),
145
+ ))
146
+ elif event_type == "reasoning":
147
+ events.append(UnifiedEvent(
148
+ kind="reasoning",
149
+ ts=ts,
150
+ text=_part_text(raw.get("part")),
151
+ ))
152
+ elif event_type == "tool_use":
153
+ events.append(UnifiedEvent(
154
+ kind="tool",
155
+ ts=ts,
156
+ tool=_extract_tool_call(raw.get("part") or {}),
157
+ ))
158
+ elif event_type == "error":
159
+ events.append(UnifiedEvent(kind="error", ts=ts))
160
+ # Anything else → emit nothing (the session event above, if any,
161
+ # already covers the session-id extraction side-effect).
162
+
163
+ return events
164
+
165
+ # ------------------------------------------------------------------
166
+ # binary / credentials
167
+ # ------------------------------------------------------------------
168
+
169
+ def binary_path(self) -> Path | None:
170
+ # Best-effort location used for dry-run display; not verified.
171
+ return (
172
+ pilot_home()
173
+ / "worker-runtime"
174
+ / "opencode"
175
+ / PINNED_OPENCODE_VERSION
176
+ / "node_modules"
177
+ / ".bin"
178
+ / "opencode"
179
+ )
180
+
181
+ def resolve_binary(self) -> Path:
182
+ # Absorbs runners/opencode.py:verify_binary behaviour, but builds the
183
+ # path locally so this module is the single source of truth for the
184
+ # runner's binary location.
185
+ binary = self.binary_path()
186
+ assert binary is not None
187
+ if not binary.is_file() or not os.access(binary, os.X_OK):
188
+ raise RuntimeError(
189
+ f"pinned OpenCode {PINNED_OPENCODE_VERSION} is missing; "
190
+ "run: bash scripts/install_runtime.sh"
191
+ )
192
+ version = subprocess.run(
193
+ [str(binary), "--version"], text=True, capture_output=True, check=False,
194
+ )
195
+ if (
196
+ version.returncode != 0
197
+ or version.stdout.strip() != PINNED_OPENCODE_VERSION
198
+ ):
199
+ actual = (version.stdout or version.stderr).strip()
200
+ raise RuntimeError(
201
+ f"expected OpenCode {PINNED_OPENCODE_VERSION}, "
202
+ f"got {actual or 'unknown'}"
203
+ )
204
+ return binary
205
+
206
+ def credential_path(self, provider: Any) -> Path:
207
+ return profile_root(provider) / "data" / "opencode" / "auth.json"
208
+
209
+ def credential_payload(self, provider: Any, key: str) -> dict:
210
+ return {provider.provider_id: {"type": "api", "key": key}}
211
+
212
+ def parse_credential(self, provider: Any, payload: dict) -> str:
213
+ # Mirrors the validation in the former runtime.credential_key
214
+ # (runtime.py:73-79).
215
+ entry = payload.get(provider.provider_id)
216
+ if not isinstance(entry, dict) or entry.get("type") != "api":
217
+ raise RuntimeError(
218
+ f"credential file lacks API auth for {provider.provider_id}"
219
+ )
220
+ key = entry.get("key")
221
+ if not isinstance(key, str) or not key.strip():
222
+ raise RuntimeError(
223
+ f"credential is empty for {provider.provider_id}"
224
+ )
225
+ return key
226
+
227
+
228
+ # ----------------------------------------------------------------------
229
+ # parse_events helpers (mirror cli/dispatch.py:_safe_int semantics exactly)
230
+ # ----------------------------------------------------------------------
231
+
232
+
233
+ def _safe_int(value: Any, default: int = 0) -> int:
234
+ if isinstance(value, bool):
235
+ return default
236
+ if isinstance(value, (int, float)):
237
+ return int(value)
238
+ return default
239
+
240
+
241
+ def _safe_int_cache(cache: Any, field: str) -> int:
242
+ if isinstance(cache, dict):
243
+ return _safe_int(cache.get(field))
244
+ return 0
245
+
246
+
247
+ def _extract_tokens(part: Any) -> TokenUsage:
248
+ if not isinstance(part, dict):
249
+ return TokenUsage()
250
+ tk = part.get("tokens")
251
+ if not isinstance(tk, dict):
252
+ return TokenUsage()
253
+ return TokenUsage(
254
+ input=_safe_int(tk.get("input")),
255
+ output=_safe_int(tk.get("output")),
256
+ reasoning=_safe_int(tk.get("reasoning")),
257
+ cache_read=_safe_int_cache(tk.get("cache"), "read"),
258
+ cache_write=_safe_int_cache(tk.get("cache"), "write"),
259
+ )
260
+
261
+
262
+ def _part_text(part: Any) -> str | None:
263
+ if not isinstance(part, dict):
264
+ return None
265
+ text = part.get("text")
266
+ return text if isinstance(text, str) else None
267
+
268
+
269
+ def _extract_tool_call(part: Any) -> ToolCall:
270
+ if not isinstance(part, dict):
271
+ part = {}
272
+ state = part.get("state") or {}
273
+ if not isinstance(state, dict):
274
+ state = {}
275
+ tool_name = part.get("tool", "?")
276
+ if not isinstance(tool_name, str):
277
+ tool_name = "?" if tool_name is None else str(tool_name)
278
+
279
+ status_value = state.get("status")
280
+ status = status_value if isinstance(status_value, str) else (
281
+ "" if status_value is None else str(status_value)
282
+ )
283
+
284
+ input_brief = _tool_input_brief(tool_name, state.get("input") or {})
285
+ output_brief = _first_line(state.get("output") or "", _INPUT_LIMIT)
286
+
287
+ error = None
288
+ if status == "error":
289
+ error_value = state.get("error")
290
+ if isinstance(error_value, str):
291
+ error = _first_line(error_value, _INPUT_LIMIT)
292
+ elif error_value is not None:
293
+ error = _first_line(str(error_value), _INPUT_LIMIT)
294
+
295
+ is_permission_denied = (
296
+ isinstance(error, str) and _PERMISSION_DENIED_MARK in error
297
+ )
298
+ silent_output = tool_name in SILENT_OUTPUT_TOOLS
299
+
300
+ return ToolCall(
301
+ name=tool_name,
302
+ status=status,
303
+ input_brief=input_brief,
304
+ output_brief=output_brief,
305
+ error=error,
306
+ is_permission_denied=is_permission_denied,
307
+ silent_output=silent_output,
308
+ )
309
+
310
+
311
+ # ----------------------------------------------------------------------
312
+ # Migrated from fmt_events.py — private, behaviour preserved verbatim.
313
+ # ----------------------------------------------------------------------
314
+
315
+
316
+ def _trim(value: Any, limit: int) -> str:
317
+ text = str(value).strip()
318
+ if len(text) <= limit:
319
+ return text
320
+ return text[: limit - 1] + "…"
321
+
322
+
323
+ def _short_path(value: str) -> str:
324
+ """~ for home, and keep only the last 3 segments of long paths."""
325
+ home = str(Path.home())
326
+ if value.startswith(home):
327
+ value = "~" + value[len(home):]
328
+ if len(value) > 64 and "/" in value:
329
+ parts = value.split("/")
330
+ if len(parts) > 4:
331
+ value = "…/" + "/".join(parts[-3:])
332
+ return value
333
+
334
+
335
+ def _first_line(value: str, limit: int) -> str:
336
+ """First informative line, never a raw newline. Skips XML-ish wrappers."""
337
+ for line in str(value).splitlines():
338
+ line = line.strip()
339
+ if not line:
340
+ continue
341
+ if line.startswith("<") and line.endswith(">"):
342
+ continue # <path>…</path> / <type>file</type> / <content> wrappers
343
+ if line.startswith("/") and " " not in line:
344
+ line = _short_path(line)
345
+ return _trim(line, limit)
346
+ return ""
347
+
348
+
349
+ def _tool_input_brief(tool: str, tool_input: dict[str, Any]) -> str:
350
+ for key in ("command", "filePath", "pattern", "path", "query", "url"):
351
+ value = tool_input.get(key)
352
+ if value:
353
+ text = str(value)
354
+ if key in ("filePath", "path"):
355
+ text = _short_path(text)
356
+ return _trim(text.replace("\n", " "), _INPUT_LIMIT)
357
+ return _trim(json.dumps(tool_input, ensure_ascii=False), _INPUT_LIMIT) if tool_input else ""
358
+
359
+
360
+ # ----------------------------------------------------------------------
361
+ # Migrated from runtime.session_ids — recursive session-id scan, used by
362
+ # parse_events. The neutral runtime no longer knows this shape.
363
+ # ----------------------------------------------------------------------
364
+
365
+
366
+ def _session_ids(value: Any) -> list[str]:
367
+ found: list[str] = []
368
+ if isinstance(value, dict):
369
+ for key, item in value.items():
370
+ if key in {"sessionID", "sessionId", "session_id"} and isinstance(item, str):
371
+ found.append(item)
372
+ else:
373
+ found.extend(_session_ids(item))
374
+ elif isinstance(value, list):
375
+ for item in value:
376
+ found.extend(_session_ids(item))
377
+ return found
@@ -0,0 +1,314 @@
1
+ #!/usr/bin/env python3
2
+ """Isolated execution runtime: environment, credentials, worktrees, process I/O."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import dataclass
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+ import subprocess
11
+ import sys
12
+ import threading
13
+ import time
14
+ from typing import Any, TextIO
15
+
16
+ from pilot_workers.providers import Provider, profile_paths
17
+ from pilot_workers.runners.base import Runner, UnifiedEvent
18
+
19
+ SAFE_ENV_KEYS = (
20
+ "HOME", "USER", "LOGNAME", "SHELL", "PATH", "TMPDIR",
21
+ "LANG", "LC_ALL",
22
+ "JAVA_HOME", "ANDROID_HOME", "ANDROID_SDK_ROOT",
23
+ "FLUTTER_ROOT", "GOPATH", "GOROOT",
24
+ "CARGO_HOME", "RUSTUP_HOME",
25
+ "NVM_DIR", "PYENV_ROOT", "RBENV_ROOT",
26
+ "BUN_INSTALL", "PNPM_HOME",
27
+ )
28
+
29
+ # Env keys a runner must never override: neutral SAFE_ENV_KEYS already owned
30
+ # by this layer, the XDG_*_HOME dirs (also owned here), plus the NO_COLOR / CI
31
+ # flags the runtime sets to keep worker output deterministic.
32
+ _PROTECTED_KEYS = frozenset(SAFE_ENV_KEYS) | frozenset(
33
+ k for k in ("NO_COLOR", "CI")
34
+ ) | frozenset(
35
+ f"XDG_{d}_HOME" for d in ("CONFIG", "DATA", "STATE", "CACHE")
36
+ )
37
+
38
+ HEARTBEAT_SECONDS = 60
39
+ TERMINATE_GRACE_SECONDS = 10
40
+
41
+
42
+ def ensure_private_directory(path: Path) -> None:
43
+ path.mkdir(mode=0o700, parents=True, exist_ok=True)
44
+ path.chmod(0o700)
45
+
46
+
47
+ def build_environment(provider: Provider, runner_env: dict[str, str]) -> dict[str, str]:
48
+ """Compose the child process environment.
49
+
50
+ Neutral concerns (SAFE_ENV_KEYS whitelist + XDG dirs) are owned here; the
51
+ runner-specific variables come from ``runner.runner_environment`` via the
52
+ ``runner_env`` argument and are merged unchanged.
53
+ """
54
+ paths = profile_paths(provider)
55
+ for name in ("root", "config", "data", "state", "cache"):
56
+ ensure_private_directory(paths[name])
57
+ env = {key: os.environ[key] for key in SAFE_ENV_KEYS if os.environ.get(key)}
58
+ env.update({
59
+ "XDG_CONFIG_HOME": str(paths["config"]),
60
+ "XDG_DATA_HOME": str(paths["data"]),
61
+ "XDG_STATE_HOME": str(paths["state"]),
62
+ "XDG_CACHE_HOME": str(paths["cache"]),
63
+ "NO_COLOR": "1",
64
+ "CI": "1",
65
+ })
66
+ # Drop any runner-supplied entry that would shadow a neutral key — those
67
+ # are owned by this layer and must remain deterministic.
68
+ filtered = {k: v for k, v in runner_env.items() if k not in _PROTECTED_KEYS}
69
+ env.update(filtered)
70
+ return env
71
+
72
+
73
+ def credential_key(provider: Provider, runner: Runner) -> str:
74
+ path = runner.credential_path(provider)
75
+ if not path.is_file():
76
+ raise RuntimeError(f"credential missing for {provider.key}; run: pilot-workers configure {provider.key}")
77
+ if path.stat().st_mode & 0o077:
78
+ raise RuntimeError(f"credential file is not private (expected mode 0600): {path}")
79
+ try:
80
+ payload = json.loads(path.read_text(encoding="utf-8"))
81
+ except (OSError, json.JSONDecodeError) as exc:
82
+ raise RuntimeError(f"cannot read credential from {path}: {exc}") from exc
83
+ return runner.parse_credential(provider, payload)
84
+
85
+
86
+ def credential_metadata(provider: Provider, runner: Runner) -> dict[str, Any]:
87
+ path = runner.credential_path(provider)
88
+ configured = False
89
+ secure_mode = False
90
+ if path.is_file():
91
+ secure_mode = (path.stat().st_mode & 0o077) == 0
92
+ try:
93
+ payload = json.loads(path.read_text(encoding="utf-8"))
94
+ configured = _looks_configured(provider, runner, payload)
95
+ except (OSError, json.JSONDecodeError):
96
+ configured = False
97
+ return {"configured": configured, "secure_mode": secure_mode, "path": str(path)}
98
+
99
+
100
+ def _looks_configured(provider: Provider, runner: Runner, payload: Any) -> bool:
101
+ """Best-effort 'has a usable API key' check; never raises."""
102
+ try:
103
+ key = runner.parse_credential(provider, payload)
104
+ except (RuntimeError, TypeError, AttributeError):
105
+ return False
106
+ return bool(key.strip())
107
+
108
+
109
+ def create_detached_worktree(workdir: Path, worktree_parent: Path) -> Path:
110
+ root_result = subprocess.run(
111
+ ["git", "-C", str(workdir), "rev-parse", "--show-toplevel"],
112
+ text=True, capture_output=True, check=False,
113
+ )
114
+ if root_result.returncode != 0:
115
+ raise RuntimeError(f"--worktree requires a Git repository: {root_result.stderr.strip()}")
116
+ repository_root = Path(root_result.stdout.strip()).resolve()
117
+ status = subprocess.run(
118
+ ["git", "-C", str(repository_root), "status", "--porcelain"],
119
+ text=True, capture_output=True, check=False,
120
+ )
121
+ if status.returncode != 0:
122
+ raise RuntimeError(f"cannot inspect Git status: {status.stderr.strip()}")
123
+ if status.stdout.strip():
124
+ raise RuntimeError("--worktree requires a clean repository (commit or stash first)")
125
+ ensure_private_directory(worktree_parent)
126
+ from datetime import datetime, timezone
127
+ import secrets as secrets_module
128
+ stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
129
+ target = worktree_parent / f"{repository_root.name}-{stamp}-{secrets_module.token_hex(3)}"
130
+ add_result = subprocess.run(
131
+ ["git", "-C", str(repository_root), "worktree", "add", "--detach", str(target), "HEAD"],
132
+ text=True, capture_output=True, check=False,
133
+ )
134
+ if add_result.returncode != 0:
135
+ raise RuntimeError(f"cannot create detached worktree: {add_result.stderr.strip()}")
136
+ try:
137
+ relative = workdir.resolve().relative_to(repository_root)
138
+ except ValueError as exc:
139
+ raise RuntimeError(f"workdir {workdir} is not inside repository {repository_root}") from exc
140
+ return target / relative
141
+
142
+
143
+ def open_private_text(path: Path) -> TextIO:
144
+ descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
145
+ return os.fdopen(descriptor, "w", encoding="utf-8")
146
+
147
+
148
+ @dataclass
149
+ class RunResult:
150
+ exit_code: int
151
+ session_id: str | None
152
+ timed_out: bool = False
153
+ idle_timed_out: bool = False
154
+ interrupted: bool = False
155
+
156
+
157
+ class _SafeRenderer:
158
+ def __init__(self, writer: Any) -> None:
159
+ self._writer = writer
160
+ self._broken = False
161
+
162
+ def _guard(self, action) -> None:
163
+ if self._broken or self._writer is None:
164
+ return
165
+ try:
166
+ action()
167
+ except Exception as exc:
168
+ self._broken = True
169
+ print(f"note: live log rendering disabled ({exc})", file=sys.stderr)
170
+
171
+ def event(self, event: dict[str, Any]) -> None:
172
+ self._guard(lambda: self._writer.write_event(event))
173
+
174
+ def raw_line(self, line: str, runner: Runner | None) -> None:
175
+ def action() -> None:
176
+ try:
177
+ event = json.loads(line)
178
+ except json.JSONDecodeError:
179
+ return
180
+ if not isinstance(event, dict):
181
+ return
182
+ if runner is None:
183
+ # Without a runner we can only render self-owned events.
184
+ self._writer.write_event(event)
185
+ return
186
+ for ev in runner.parse_events(event):
187
+ self._writer.write_unified(ev)
188
+ # Self-owned events (worker_runner.*) live outside parse_events;
189
+ # also render them in case the line is one.
190
+ self._writer.write_event(event)
191
+ self._guard(action)
192
+
193
+ def finalize(self) -> None:
194
+ self._guard(lambda: self._writer.finalize())
195
+
196
+
197
+ def run_process(
198
+ command: list[str], env: dict[str, str], task: str,
199
+ log_path: Path, stderr_path: Path, secret: str,
200
+ renderer: Any = None, timeout_s: int = 0, idle_timeout_s: int = 0,
201
+ runner: Runner | None = None,
202
+ ) -> RunResult:
203
+ safe_renderer = _SafeRenderer(renderer)
204
+ result = RunResult(exit_code=1, session_id=None)
205
+ last_activity = time.monotonic()
206
+ started_at = last_activity
207
+ lock = threading.Lock()
208
+
209
+ def redact(value: str) -> str:
210
+ return value.replace(secret, "[REDACTED]") if secret else value
211
+
212
+ with open_private_text(log_path) as stdout_log, open_private_text(stderr_path) as stderr_log:
213
+ process = subprocess.Popen(
214
+ command, env=env, stdin=subprocess.PIPE,
215
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1,
216
+ )
217
+
218
+ def feed_stdin() -> None:
219
+ try:
220
+ assert process.stdin is not None
221
+ process.stdin.write(task)
222
+ process.stdin.close()
223
+ except (BrokenPipeError, OSError):
224
+ pass
225
+
226
+ def touch() -> None:
227
+ nonlocal last_activity
228
+ with lock:
229
+ last_activity = time.monotonic()
230
+
231
+ def consume_stdout() -> None:
232
+ assert process.stdout is not None
233
+ for raw_line in process.stdout:
234
+ touch()
235
+ line = redact(raw_line)
236
+ stdout_log.write(line); stdout_log.flush()
237
+ sys.stdout.write(line); sys.stdout.flush()
238
+ safe_renderer.raw_line(line, runner)
239
+ try:
240
+ event = json.loads(line)
241
+ except json.JSONDecodeError:
242
+ continue
243
+ if not isinstance(event, dict) or runner is None:
244
+ continue
245
+ try:
246
+ for ev in runner.parse_events(event):
247
+ if ev.kind == "session" and ev.session_id:
248
+ result.session_id = ev.session_id
249
+ except Exception:
250
+ pass
251
+
252
+ def consume_stderr() -> None:
253
+ assert process.stderr is not None
254
+ for raw_line in process.stderr:
255
+ touch()
256
+ line = redact(raw_line)
257
+ stderr_log.write(line); stderr_log.flush()
258
+ sys.stderr.write(line); sys.stderr.flush()
259
+
260
+ threads = [
261
+ threading.Thread(target=feed_stdin, daemon=True),
262
+ threading.Thread(target=consume_stdout, daemon=True),
263
+ threading.Thread(target=consume_stderr, daemon=True),
264
+ ]
265
+ for thread in threads:
266
+ thread.start()
267
+
268
+ def stop_child() -> None:
269
+ process.terminate()
270
+ try:
271
+ process.wait(timeout=TERMINATE_GRACE_SECONDS)
272
+ except subprocess.TimeoutExpired:
273
+ process.kill(); process.wait()
274
+
275
+ def emit_runner_event(event: dict[str, Any]) -> None:
276
+ payload = json.dumps(event)
277
+ stderr_log.write(payload + "\n"); stderr_log.flush()
278
+ sys.stderr.write(payload + "\n"); sys.stderr.flush()
279
+ safe_renderer.event(event)
280
+
281
+ next_heartbeat_silence = HEARTBEAT_SECONDS
282
+ try:
283
+ while True:
284
+ try:
285
+ result.exit_code = process.wait(timeout=1)
286
+ break
287
+ except subprocess.TimeoutExpired:
288
+ pass
289
+ now = time.monotonic()
290
+ with lock:
291
+ silent = now - last_activity
292
+ elapsed = now - started_at
293
+ if timeout_s and elapsed >= timeout_s:
294
+ result.timed_out = True; stop_child()
295
+ result.exit_code = process.returncode or 124; break
296
+ if idle_timeout_s and silent >= idle_timeout_s:
297
+ result.idle_timed_out = True; stop_child()
298
+ result.exit_code = process.returncode or 124; break
299
+ if silent >= next_heartbeat_silence:
300
+ emit_runner_event({"type": "worker_runner.heartbeat",
301
+ "elapsed_s": int(elapsed), "silent_s": int(silent)})
302
+ next_heartbeat_silence = silent + HEARTBEAT_SECONDS
303
+ else:
304
+ next_heartbeat_silence = max(next_heartbeat_silence, HEARTBEAT_SECONDS)
305
+ except KeyboardInterrupt:
306
+ result.interrupted = True; stop_child()
307
+ result.exit_code = process.returncode or 130
308
+ finally:
309
+ for thread in threads:
310
+ thread.join(timeout=5)
311
+
312
+ if (result.timed_out or result.idle_timed_out or result.interrupted) and result.exit_code == 0:
313
+ result.exit_code = 124
314
+ return result