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,295 @@
1
+ #!/usr/bin/env python3
2
+ """Mode policy: agents, shell permission rules, prompts, and runner config.
3
+
4
+ Permission-matching facts (read from the pinned OpenCode binary,
5
+ confirmed by live probes):
6
+
7
+ - Resolution is LAST-MATCH-WINS (`findLast` over insertion-ordered rules).
8
+ Deny rules must be inserted AFTER the allow rules they override.
9
+ - Each bash command is parsed with tree-sitter; every AST command node is
10
+ checked separately. Operator-symbol rules (`*|*`, `*&&*`, etc.) are dead
11
+ weight — each segment already hits the real allow/deny on its own.
12
+ - Redirected statements match with FULL text, so read-only modes keep a
13
+ `*>*` deny inserted LAST to block redirect writes.
14
+
15
+ This layer guards against mistakes, not malicious models. Not an OS sandbox.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ try:
24
+ import yaml # type: ignore[import-untyped]
25
+ except ImportError:
26
+ yaml = None # type: ignore[assignment]
27
+
28
+ from pilot_workers.providers import Provider
29
+
30
+ PERMISSIONS_DIR = Path(__file__).resolve().parent / "data" / "permissions"
31
+
32
+ VALID_MODES = ("code", "explore", "test", "review")
33
+
34
+ MODE_TO_AGENT = {
35
+ "code": "worker-code",
36
+ "explore": "worker-explore",
37
+ "test": "worker-test",
38
+ "review": "worker-review",
39
+ "resume": "worker-code",
40
+ }
41
+
42
+ STEPS_BY_MODE = {"code": 120, "resume": 120, "review": 120, "explore": 80, "test": 80}
43
+
44
+ PROMPTS_DIR = Path(__file__).resolve().parent / "prompts"
45
+
46
+
47
+ def load_prompt(mode: str) -> str:
48
+ prompt_mode = "code" if mode == "resume" else mode
49
+ parts = []
50
+ for name in ("common.md", f"{prompt_mode}.md"):
51
+ path = PROMPTS_DIR / name
52
+ if not path.is_file():
53
+ raise RuntimeError(f"worker prompt file missing: {path}")
54
+ text = path.read_text(encoding="utf-8").strip()
55
+ if not text:
56
+ raise RuntimeError(f"worker prompt file is empty: {path}")
57
+ parts.append(text)
58
+ return "\n\n".join(parts)
59
+
60
+
61
+ def denied_shell_patterns() -> dict[str, str]:
62
+ return {
63
+ "git push*": "deny",
64
+ "git pull*": "deny",
65
+ "git fetch*": "deny",
66
+ "git clone*": "deny",
67
+ "git remote add*": "deny",
68
+ "gh *": "deny",
69
+ "curl *": "deny",
70
+ "wget *": "deny",
71
+ "ssh *": "deny",
72
+ "scp *": "deny",
73
+ "sftp *": "deny",
74
+ "rsync *": "deny",
75
+ "nc *": "deny",
76
+ "ncat *": "deny",
77
+ "npm publish*": "deny",
78
+ "pnpm publish*": "deny",
79
+ "yarn publish*": "deny",
80
+ "rm -rf /*": "deny",
81
+ "rm -rf ~*": "deny",
82
+ "sudo *": "deny",
83
+ "*auth.json*": "deny",
84
+ "*.env*": "deny",
85
+ }
86
+
87
+
88
+ def readonly_shell_permissions() -> dict[str, str]:
89
+ rules = {
90
+ "*": "deny",
91
+ "pwd": "allow",
92
+ "ls*": "allow",
93
+ "cat *": "allow",
94
+ "echo *": "allow",
95
+ "find *": "allow",
96
+ "rg *": "allow",
97
+ "grep *": "allow",
98
+ "sed *": "allow",
99
+ "awk *": "allow",
100
+ "head *": "allow",
101
+ "tail *": "allow",
102
+ "wc *": "allow",
103
+ "file *": "allow",
104
+ "stat *": "allow",
105
+ "npx tsc*": "allow",
106
+ "git status*": "allow",
107
+ "git diff*": "allow",
108
+ "git log*": "allow",
109
+ "git show*": "allow",
110
+ "git blame*": "allow",
111
+ "git branch*": "allow",
112
+ "git grep*": "allow",
113
+ "git rev-parse*": "allow",
114
+ "git ls-files*": "allow",
115
+ }
116
+ rules.update(denied_shell_patterns())
117
+ rules["*>*"] = "deny"
118
+ return rules
119
+
120
+
121
+ def test_shell_permissions() -> dict[str, str]:
122
+ rules = readonly_shell_permissions()
123
+ rules.update(
124
+ {
125
+ "npm test*": "allow",
126
+ "npm run test*": "allow",
127
+ "npm run lint*": "allow",
128
+ "npm run typecheck*": "allow",
129
+ "npm run build*": "allow",
130
+ "pnpm test*": "allow",
131
+ "pnpm run test*": "allow",
132
+ "pnpm run lint*": "allow",
133
+ "pnpm run typecheck*": "allow",
134
+ "pnpm run build*": "allow",
135
+ "yarn test*": "allow",
136
+ "yarn lint*": "allow",
137
+ "yarn build*": "allow",
138
+ "bun test*": "allow",
139
+ "pytest*": "allow",
140
+ "python -m pytest*": "allow",
141
+ "go test*": "allow",
142
+ "cargo test*": "allow",
143
+ "npx vitest run*": "allow",
144
+ "dart test*": "allow",
145
+ "flutter test*": "allow",
146
+ "make test*": "allow",
147
+ "make check*": "allow",
148
+ "make lint*": "allow",
149
+ "just test*": "allow",
150
+ "composer test*": "allow",
151
+ "php artisan test*": "allow",
152
+ "mvn test*": "allow",
153
+ "./gradlew test*": "allow",
154
+ }
155
+ )
156
+ rules.update(denied_shell_patterns())
157
+ return rules
158
+
159
+
160
+ def code_shell_permissions() -> dict[str, str]:
161
+ rules = {"*": "allow"}
162
+ rules.update(denied_shell_patterns())
163
+ return rules
164
+
165
+
166
+ def agent_permissions(mode: str) -> dict[str, Any]:
167
+ effective_mode = "code" if mode == "resume" else mode
168
+ editable = effective_mode == "code"
169
+ if effective_mode == "code":
170
+ bash = code_shell_permissions()
171
+ elif effective_mode == "test":
172
+ bash = test_shell_permissions()
173
+ else:
174
+ bash = readonly_shell_permissions()
175
+ return {
176
+ "*": "deny",
177
+ "read": "allow",
178
+ "edit": "allow" if editable else "deny",
179
+ "glob": "allow",
180
+ "grep": "allow",
181
+ "list": "allow",
182
+ "bash": bash,
183
+ "task": "deny",
184
+ "todowrite": "allow",
185
+ "webfetch": "deny",
186
+ "websearch": "deny",
187
+ "external_directory": "deny",
188
+ "lsp": "allow",
189
+ "skill": "deny",
190
+ "question": "deny",
191
+ "doom_loop": "deny",
192
+ "mcp_*": "deny",
193
+ }
194
+
195
+
196
+ def load_permission_profile(name: str) -> dict[str, Any]:
197
+ """Load a permission profile YAML from the permissions/ directory."""
198
+ path = PERMISSIONS_DIR / f"{name}.yaml"
199
+ if not path.is_file():
200
+ raise RuntimeError(f"permission profile not found: {path}")
201
+ if yaml is None:
202
+ raise RuntimeError(
203
+ f"pyyaml is required for custom permission profiles; "
204
+ f"install it with: pip install pyyaml"
205
+ )
206
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
207
+ if not isinstance(data, dict):
208
+ raise RuntimeError(f"permission profile must be a YAML mapping: {path}")
209
+ for key in data:
210
+ if key != "_all" and key not in VALID_MODES:
211
+ raise RuntimeError(
212
+ f"permission profile has unknown section '{key}' "
213
+ f"(expected _all or one of {VALID_MODES}): {path}"
214
+ )
215
+ return data
216
+
217
+
218
+ def _merge_permissions(
219
+ base: dict[str, Any], profile: dict[str, Any] | None, mode: str,
220
+ ) -> dict[str, Any]:
221
+ """Merge a permission profile into base agent permissions.
222
+
223
+ Shell rules from the profile are appended after the base rules
224
+ (last-match-wins in OpenCode). Tool rules overwrite base values.
225
+ """
226
+ if profile is None:
227
+ return base
228
+
229
+ sections = []
230
+ if "_all" in profile:
231
+ sections.append(profile["_all"])
232
+ effective_mode = "code" if mode == "resume" else mode
233
+ if effective_mode in profile:
234
+ sections.append(profile[effective_mode])
235
+ if not sections:
236
+ return base
237
+
238
+ result = dict(base)
239
+ bash_rules = dict(result.get("bash", {}))
240
+
241
+ for section in sections:
242
+ if not isinstance(section, dict):
243
+ continue
244
+ for pattern, action in (section.get("shell") or {}).items():
245
+ bash_rules[pattern] = action
246
+ for tool, action in (section.get("tools") or {}).items():
247
+ result[tool] = action
248
+
249
+ result["bash"] = bash_rules
250
+ return result
251
+
252
+
253
+ def build_config(provider: Provider, mode: str, *, permission_profile: str | None = None) -> dict[str, Any]:
254
+ profile_name = permission_profile or provider.permissions
255
+ profile = load_permission_profile(profile_name) if profile_name else None
256
+ agent_name = MODE_TO_AGENT[mode]
257
+ prompt_mode = "code" if mode == "resume" else mode
258
+ model = provider.model
259
+ permissions = _merge_permissions(agent_permissions(mode), profile, mode)
260
+ return {
261
+ "$schema": "https://opencode.ai/config.json",
262
+ "autoupdate": False,
263
+ "share": "disabled",
264
+ "model": model,
265
+ "small_model": model,
266
+ "default_agent": agent_name,
267
+ "enabled_providers": [provider.provider_id],
268
+ "provider": {
269
+ provider.provider_id: {
270
+ "npm": "@ai-sdk/openai-compatible",
271
+ "name": provider.display_name,
272
+ "options": {"baseURL": provider.base_url},
273
+ "models": {
274
+ provider.model_id: {
275
+ "name": provider.display_name,
276
+ "limit": {
277
+ "context": provider.context_tokens,
278
+ "output": provider.output_tokens,
279
+ },
280
+ }
281
+ },
282
+ }
283
+ },
284
+ "permission": {"*": "deny"},
285
+ "agent": {
286
+ agent_name: {
287
+ "description": f"Isolated {prompt_mode} worker controlled by the main planner",
288
+ "mode": "primary",
289
+ "model": model,
290
+ "steps": STEPS_BY_MODE[mode],
291
+ "prompt": load_prompt(mode),
292
+ "permission": permissions,
293
+ }
294
+ },
295
+ }
@@ -0,0 +1,6 @@
1
+ Mode: code (edits allowed).
2
+
3
+ - Implement only the approved behavior. Make the smallest convention-aligned edits; follow the codebase's existing patterns and naming.
4
+ - If the task gives a file whitelist, never touch files outside it.
5
+ - Self-validate before finishing: run the checks the task specifies; if none are specified, use the project's standard test/lint commands. Do not finish with failing checks unless you can prove the failure is pre-existing — then include that proof in VALIDATION.
6
+ - Report problems you noticed but deliberately did not touch (out-of-scope, pre-existing, suspicious) in REMAINING_RISKS.
@@ -0,0 +1,24 @@
1
+ You are an execution worker controlled by a main planner process (Codex or Claude). Follow the supplied task contract exactly. Do not invent requirements, broaden scope, delegate to subagents, access credentials, share the session, or contact unrelated network services. Inspect the named implementation paths before making claims. Fail visibly when a required action is blocked.
2
+
3
+ Workspace discipline:
4
+
5
+ - The workspace may contain pre-existing uncommitted changes. That is normal: do not explain them, do not revert them, and do not count them in your own change list.
6
+ - Stay inside the given work directory. Do not touch paths outside it.
7
+
8
+ Permission preview — these command classes are denied at the permission layer, in every mode. Do not attempt them; if the task seems to require one, stop and report it as blocked instead of retrying or working around it:
9
+
10
+ - Remote Git operations (`git push/pull/fetch/clone/remote add`, `gh`).
11
+ - Network clients (`curl`, `wget`, `ssh`, `scp`, `sftp`, `rsync`, `nc`).
12
+ - Package publishing (`npm/pnpm/yarn publish`).
13
+ - Credential paths (anything touching `auth.json` or `.env` files), `sudo`, and destructive root/home deletion.
14
+
15
+ A blocked call returns a permission error once; never retry it verbatim.
16
+
17
+ Final report — end with exactly these four sections:
18
+
19
+ 1. `STATUS`: complete, incomplete, or blocked.
20
+ 2. `FILES_CHANGED`: exact paths with a one-line purpose each, or `none`.
21
+ 3. `VALIDATION`: the commands you ran and their verbatim key output (counts, failing test names, error text). Quote real output; never paraphrase it.
22
+ 4. `REMAINING_RISKS`: unmet boundaries, assumptions, pre-existing problems you noticed but did not touch, or `none`.
23
+
24
+ The main planner will independently review and verify your work; your completion claim is not the acceptance decision.
@@ -0,0 +1,7 @@
1
+ Mode: explore (read-only; file edits are denied at the permission layer).
2
+
3
+ - Investigate read-only. Do not edit files; do not retry denied write attempts.
4
+ - Every conclusion must carry a `file:line` reference. A conclusion without a reference is invalid.
5
+ - Output structured items, one fact per item; no preamble, summaries, or commentary.
6
+ - Quote at most 3 lines of code per item; for anything longer give the `file:line` reference instead.
7
+ - Cap conclusions at 20 items unless the task sets a different budget; if you exceed the cap, list the most important ones and note how many more exist and in which directories.
@@ -0,0 +1,5 @@
1
+ Mode: review (read-only; file edits are denied at the permission layer).
2
+
3
+ - Review read-only. Do not edit files and do not fix anything.
4
+ - Report only substantiated findings: each finding needs a severity, an exact `file:line` location, the observed behavior, the concrete impact, and a specific fix direction.
5
+ - Do not pad the report with style opinions or unverified suspicions; if something is a hunch, label it as unverified in one line or drop it.
@@ -0,0 +1,6 @@
1
+ Mode: test (read-only for source; only test/lint/build and read-only commands are allowed).
2
+
3
+ - Validate without editing source files.
4
+ - Run only the approved checks from the task contract.
5
+ - Report complete failure output verbatim: full failing test names, assertion messages, and exit statuses — enough for the planner to diagnose without rerunning.
6
+ - Do not attempt fixes; diagnosis and fixing belong to the planner.
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env python3
2
+ """Provider registry: loads provider definitions from YAML files.
3
+
4
+ The single source of truth for provider routing is the `data/providers/`
5
+ directory inside this package. Each `.yaml` file defines one provider.
6
+ Adding a new model only requires dropping a new YAML file — no Python changes.
7
+
8
+ The shared path helpers also live here so that every other module imports
9
+ facts from one place.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass
15
+ import os
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ try:
20
+ import yaml # type: ignore[import-untyped]
21
+ except ImportError:
22
+ yaml = None # type: ignore[assignment]
23
+
24
+
25
+ MAX_TASK_BYTES = 512_000
26
+
27
+ PROVIDERS_DIR = Path(__file__).resolve().parent / "data" / "providers"
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Provider:
32
+ key: str
33
+ provider_id: str
34
+ model_id: str
35
+ base_url: str
36
+ display_name: str
37
+ context_tokens: int
38
+ output_tokens: int
39
+ permissions: str | None = None
40
+ runner: str = "opencode"
41
+
42
+ @property
43
+ def model(self) -> str:
44
+ return f"{self.provider_id}/{self.model_id}"
45
+
46
+
47
+ def _parse_yaml(path: Path) -> dict[str, Any]:
48
+ """Parse a YAML file, with a stdlib fallback for simple flat files."""
49
+ text = path.read_text(encoding="utf-8")
50
+ if yaml is not None:
51
+ return yaml.safe_load(text)
52
+ # Minimal fallback: flat key: value files without nesting.
53
+ result: dict[str, Any] = {}
54
+ for line in text.splitlines():
55
+ line = line.strip()
56
+ if not line or line.startswith("#"):
57
+ continue
58
+ if ":" not in line:
59
+ continue
60
+ key, _, value = line.partition(":")
61
+ value = value.strip()
62
+ if value.isdigit():
63
+ result[key.strip()] = int(value)
64
+ else:
65
+ result[key.strip()] = value
66
+ return result
67
+
68
+
69
+ def load_providers(providers_dir: Path | None = None) -> dict[str, Provider]:
70
+ """Discover and load all provider YAML files from the given directory."""
71
+ directory = providers_dir or PROVIDERS_DIR
72
+ if not directory.is_dir():
73
+ raise RuntimeError(f"providers directory does not exist: {directory}")
74
+ providers: dict[str, Provider] = {}
75
+ for path in sorted(directory.glob("*.yaml")):
76
+ data = _parse_yaml(path)
77
+ required = ("key", "provider_id", "model_id", "base_url", "display_name",
78
+ "context_tokens", "output_tokens")
79
+ missing = [f for f in required if f not in data]
80
+ if missing:
81
+ raise RuntimeError(f"provider {path.name} missing fields: {', '.join(missing)}")
82
+ provider = Provider(
83
+ key=data["key"],
84
+ provider_id=data["provider_id"],
85
+ model_id=data["model_id"],
86
+ base_url=data["base_url"],
87
+ display_name=data["display_name"],
88
+ context_tokens=int(data["context_tokens"]),
89
+ output_tokens=int(data["output_tokens"]),
90
+ permissions=data.get("permissions") or None,
91
+ runner=data.get("runner") or "opencode",
92
+ )
93
+ if provider.key in providers:
94
+ raise RuntimeError(f"duplicate provider key: {provider.key}")
95
+ providers[provider.key] = provider
96
+ if not providers:
97
+ raise RuntimeError(f"no provider YAML files found in {directory}")
98
+ return providers
99
+
100
+
101
+ # Eagerly loaded at import time for backwards compatibility with existing code
102
+ # that does `from providers import PROVIDERS`. Callers that need a custom
103
+ # directory can call load_providers() directly.
104
+ PROVIDERS = load_providers()
105
+
106
+
107
+ def pilot_home() -> Path:
108
+ """Root for all pilot-workers runtime data (credentials, logs, worktrees)."""
109
+ return Path(os.environ.get("PILOT_WORKERS_HOME",
110
+ os.environ.get("CODEX_HOME", Path.home() / ".codex"))).expanduser().resolve()
111
+
112
+
113
+ def workers_root() -> Path:
114
+ return pilot_home() / "opencode-workers"
115
+
116
+
117
+ def profile_root(provider: Provider) -> Path:
118
+ return workers_root() / "providers" / provider.key
119
+
120
+
121
+ def profile_paths(provider: Provider) -> dict[str, Path]:
122
+ root = profile_root(provider)
123
+ return {
124
+ "root": root,
125
+ "config": root / "config",
126
+ "data": root / "data",
127
+ "state": root / "state",
128
+ "cache": root / "cache",
129
+ }
130
+
131
+
132
+ def logs_root(provider: Provider) -> Path:
133
+ return workers_root() / "logs" / provider.key
134
+
135
+
136
+ def worktrees_root() -> Path:
137
+ return workers_root() / "worktrees"
@@ -0,0 +1,28 @@
1
+ """Runner abstraction layer.
2
+
3
+ A runner is the execution carrier that takes a task contract and runs it
4
+ through a model. OpenCode is the first implementation; others (Aider,
5
+ Continue, etc.) can be added by implementing the same interface.
6
+
7
+ The registry below is the single entry point: callers ask for a runner by
8
+ name via ``get_runner`` and receive a singleton ``Runner`` instance.
9
+ """
10
+
11
+ from pilot_workers.runners.base import (
12
+ Runner,
13
+ TokenUsage,
14
+ ToolCall,
15
+ UnifiedEvent,
16
+ )
17
+ from pilot_workers.runners.opencode_runner import OpenCodeRunner
18
+
19
+ RUNNERS: dict[str, Runner] = {"opencode": OpenCodeRunner()}
20
+
21
+
22
+ def get_runner(name: str) -> Runner:
23
+ try:
24
+ return RUNNERS[name]
25
+ except KeyError:
26
+ raise RuntimeError(
27
+ f"unknown runner: {name} (available: {sorted(RUNNERS)})"
28
+ )
@@ -0,0 +1,123 @@
1
+ """Runner abstraction: unified event model + Runner interface.
2
+
3
+ A runner is the execution carrier that takes a task contract and runs it
4
+ through a model. The unified event types below are the lingua franca that
5
+ dispatchers, renderers, and verdict logic consume; each concrete Runner
6
+ parses its engine's native events into this shape on the read side.
7
+
8
+ Design notes:
9
+
10
+ - `worker_runner.started/summary/heartbeat/verdict` are pilot-workers-owned
11
+ events; they bypass `parse_events` and go straight to rendering/verdict.
12
+ - `kind="step"` must fire exactly once per engine step; `build_config` must
13
+ hard-stop the engine at its step cap. STEPS_BY_MODE is currently calibrated
14
+ to OpenCode's step granularity and MUST be re-calibrated when a new runner
15
+ is added.
16
+ - Runners that do not support session resume MUST raise RuntimeError on a
17
+ non-None `session` argument to `build_command` rather than silently ignore.
18
+ - The on-disk JSONL always stores the runner's raw events; `parse_events`
19
+ only translates on the read side.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from abc import ABC, abstractmethod
25
+ from dataclasses import dataclass
26
+ from pathlib import Path
27
+ from typing import Any, Literal
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class TokenUsage:
32
+ input: int = 0
33
+ output: int = 0
34
+ reasoning: int = 0
35
+ cache_read: int = 0
36
+ cache_write: int = 0
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class ToolCall:
41
+ name: str
42
+ status: str # runner-reported status, e.g. "completed" / "error"
43
+ input_brief: str # single-line human-readable summary, "" if none
44
+ output_brief: str # first informative output line, "" if none
45
+ error: str | None # error text when status == "error"
46
+ is_permission_denied: bool # whether the runner flagged a permission denial
47
+ silent_output: bool # True = renderer should hide the output line
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class UnifiedEvent:
52
+ kind: Literal["step", "text", "reasoning", "tool", "error", "session"]
53
+ ts: int | None = None # epoch milliseconds of the raw event, None if absent
54
+ text: str | None = None # text/reasoning payload
55
+ tokens: TokenUsage | None = None # step usage
56
+ tool: ToolCall | None = None # tool invocation info
57
+ session_id: str | None = None # session id for kind="session"
58
+
59
+
60
+ class Runner(ABC):
61
+ """Worker runner adapter.
62
+
63
+ Contract (required reading for new runner implementations):
64
+
65
+ - `worker_runner.started/summary/heartbeat/verdict` are pilot-workers-owned
66
+ events; they do not flow through parse_events and reach rendering and
67
+ verdict logic directly.
68
+ - `kind="step"` must fire exactly once per engine step; build_config must
69
+ make the engine hard-stop at its steps cap. STEPS_BY_MODE is currently
70
+ calibrated to OpenCode's step granularity and MUST be re-calibrated
71
+ when a new runner is wired in.
72
+ - Runners that do not support session resume MUST raise RuntimeError on a
73
+ non-None `session` argument; they MUST NOT silently ignore it.
74
+ - The on-disk JSONL always stores the runner's raw events; parse_events
75
+ only translates on the read side.
76
+ """
77
+
78
+ name: str
79
+
80
+ @abstractmethod
81
+ def build_config(self, provider: Any, mode: str, permission_profile: str | None = None) -> dict: ...
82
+
83
+ @abstractmethod
84
+ def build_command(self, binary: Path, provider: Any, mode: str,
85
+ workdir: Path, run_id: str, session: str | None) -> list[str]: ...
86
+
87
+ @abstractmethod
88
+ def runner_environment(self, provider: Any, config: dict) -> dict[str, str]:
89
+ """Only the env vars specific to this runner; neutral parts
90
+ (SAFE_ENV_KEYS / XDG) are owned by the runtime layer."""
91
+
92
+ @abstractmethod
93
+ def format_task_input(self, task: str, mode: str) -> str:
94
+ """Wrap the task text into the engine's expected first-turn input
95
+ (delivered via stdin)."""
96
+
97
+ @abstractmethod
98
+ def parse_events(self, raw: dict) -> list[UnifiedEvent]:
99
+ """Translate one raw event into 0..n unified events.
100
+ Unrecognized events return []."""
101
+
102
+ @abstractmethod
103
+ def resolve_binary(self) -> Path:
104
+ """Locate and verify the runner executable; raise RuntimeError if it
105
+ is missing or its version does not match."""
106
+
107
+ @abstractmethod
108
+ def credential_path(self, provider: Any) -> Path: ...
109
+
110
+ @abstractmethod
111
+ def credential_payload(self, provider: Any, key: str) -> dict:
112
+ """Produce the credential-file payload structure; the actual file
113
+ write (atomic write / 0600 mode) is owned by the neutral layer."""
114
+
115
+ @abstractmethod
116
+ def parse_credential(self, provider: Any, payload: dict) -> str:
117
+ """Extract the API key from a credential-file payload; raise
118
+ RuntimeError if the shape does not match."""
119
+
120
+ def binary_path(self) -> Path | None:
121
+ """Best-effort binary location WITHOUT verification (for dry-run display).
122
+ Default: None (unknown until resolve_binary)."""
123
+ return None