okstra 0.163.2 → 0.165.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.
Files changed (167) hide show
  1. package/README.md +8 -6
  2. package/docs/architecture.md +24 -15
  3. package/docs/cli.md +15 -8
  4. package/docs/for-ai/README.md +2 -2
  5. package/docs/for-ai/skills/okstra-inspect.md +2 -2
  6. package/docs/for-ai/skills/okstra-user-response.md +2 -2
  7. package/docs/project-structure-overview.md +22 -14
  8. package/package.json +1 -1
  9. package/runtime/BUILD.json +2 -2
  10. package/runtime/agents/workers/antigravity-worker.md +9 -7
  11. package/runtime/agents/workers/claude-worker.md +1 -0
  12. package/runtime/agents/workers/codex-worker.md +9 -7
  13. package/runtime/agents/workers/grok-worker.md +6 -4
  14. package/runtime/agents/workers/kimi-worker.md +6 -4
  15. package/runtime/bin/lib/okstra/cli.sh +5 -0
  16. package/runtime/bin/lib/okstra/globals.sh +2 -0
  17. package/runtime/bin/lib/okstra/usage.sh +5 -5
  18. package/runtime/bin/okstra-antigravity-exec.sh +1 -340
  19. package/runtime/bin/okstra-claude-exec.sh +1 -178
  20. package/runtime/bin/okstra-codex-exec.sh +1 -467
  21. package/runtime/bin/okstra-provider-exec.py +165 -190
  22. package/runtime/bin/okstra-trace-cleanup.sh +14 -7
  23. package/runtime/bin/okstra-wrapper-status.py +26 -19
  24. package/runtime/bin/okstra.sh +87 -91
  25. package/runtime/prompts/lead/adapters/cmux.md +2 -2
  26. package/runtime/prompts/lead/convergence.md +36 -8
  27. package/runtime/prompts/lead/okstra-lead-contract.md +24 -1
  28. package/runtime/prompts/lead/plan-body-verification.md +9 -1
  29. package/runtime/prompts/lead/report-writer.md +1 -0
  30. package/runtime/prompts/lead/team-contract.md +3 -3
  31. package/runtime/prompts/profiles/_common-contract.md +9 -1
  32. package/runtime/prompts/profiles/_coverage-critic.md +1 -1
  33. package/runtime/prompts/profiles/_implementation-diff-review.md +3 -1
  34. package/runtime/prompts/profiles/_implementation-self-check.md +1 -1
  35. package/runtime/prompts/profiles/_implementation-verifier.md +3 -1
  36. package/runtime/prompts/profiles/implementation-planning.md +6 -4
  37. package/runtime/python/okstra_ctl/adapters/accounting/__init__.py +11 -0
  38. package/runtime/python/okstra_ctl/adapters/accounting/claude_jsonl.py +17 -0
  39. package/runtime/python/okstra_ctl/adapters/accounting/cli_artifact.py +17 -0
  40. package/runtime/python/okstra_ctl/adapters/accounting/unavailable.py +19 -0
  41. package/runtime/python/okstra_ctl/adapters/dispatch/__init__.py +92 -0
  42. package/runtime/python/okstra_ctl/adapters/dispatch/cli_wrapper.py +54 -0
  43. package/runtime/python/okstra_ctl/adapters/dispatch/cmux.py +68 -0
  44. package/runtime/python/okstra_ctl/adapters/dispatch/native_team.py +13 -0
  45. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/adapter.py +60 -0
  46. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/manifest.json +1 -0
  47. package/runtime/{prompts/lead/adapters/antigravity.md → python/okstra_ctl/adapters/hosts/antigravity/relay.md} +52 -0
  48. package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +292 -0
  49. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +120 -0
  50. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/manifest.json +1 -0
  51. package/runtime/{prompts/lead/adapters/claude-code.md → python/okstra_ctl/adapters/hosts/claude-code/relay.md} +112 -1
  52. package/runtime/python/okstra_ctl/adapters/hosts/codex/adapter.py +60 -0
  53. package/runtime/python/okstra_ctl/adapters/hosts/codex/manifest.json +1 -0
  54. package/runtime/{prompts/lead/adapters/codex.md → python/okstra_ctl/adapters/hosts/codex/relay.md} +52 -0
  55. package/runtime/python/okstra_ctl/adapters/hosts/external/adapter.py +72 -0
  56. package/runtime/python/okstra_ctl/adapters/hosts/external/manifest.json +1 -0
  57. package/runtime/{prompts/lead/adapters/external.md → python/okstra_ctl/adapters/hosts/external/relay.md} +53 -1
  58. package/runtime/python/okstra_ctl/adapters/hosts/grok/adapter.py +63 -0
  59. package/runtime/python/okstra_ctl/adapters/hosts/grok/manifest.json +1 -0
  60. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +90 -0
  61. package/runtime/python/okstra_ctl/adapters/hosts/kimi/adapter.py +63 -0
  62. package/runtime/python/okstra_ctl/adapters/hosts/kimi/manifest.json +1 -0
  63. package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +90 -0
  64. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +183 -0
  65. package/runtime/python/okstra_ctl/adapters/providers/antigravity/manifest.json +1 -0
  66. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +110 -0
  67. package/runtime/python/okstra_ctl/adapters/providers/claude/manifest.json +1 -0
  68. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +84 -0
  69. package/runtime/python/okstra_ctl/adapters/providers/codex/manifest.json +1 -0
  70. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +76 -0
  71. package/runtime/python/okstra_ctl/adapters/providers/grok/manifest.json +1 -0
  72. package/runtime/python/okstra_ctl/adapters/providers/kimi/adapter.py +80 -0
  73. package/runtime/python/okstra_ctl/adapters/providers/kimi/manifest.json +1 -0
  74. package/runtime/python/okstra_ctl/application/__init__.py +1 -0
  75. package/runtime/python/okstra_ctl/application/advance_wizard.py +25 -0
  76. package/runtime/python/okstra_ctl/application/collect_usage.py +15 -0
  77. package/runtime/python/okstra_ctl/application/dispatch_assignments.py +15 -0
  78. package/runtime/python/okstra_ctl/application/resolve_assignment.py +93 -0
  79. package/runtime/python/okstra_ctl/application/resume_run.py +21 -0
  80. package/runtime/python/okstra_ctl/application/start_run.py +21 -0
  81. package/runtime/python/okstra_ctl/codex_dispatch.py +49 -826
  82. package/runtime/python/okstra_ctl/dispatch_core.py +244 -29
  83. package/runtime/python/okstra_ctl/dispatch_state.py +27 -0
  84. package/runtime/python/okstra_ctl/domain/__init__.py +34 -0
  85. package/runtime/python/okstra_ctl/domain/host.py +100 -0
  86. package/runtime/python/okstra_ctl/domain/provider.py +70 -0
  87. package/runtime/python/okstra_ctl/domain/wizard/__init__.py +19 -0
  88. package/runtime/python/okstra_ctl/domain/wizard/interaction.py +140 -0
  89. package/runtime/python/okstra_ctl/domain/worker_exec.py +102 -0
  90. package/runtime/python/okstra_ctl/domain/worker_role.py +34 -0
  91. package/runtime/python/okstra_ctl/domain/worker_stream.py +261 -0
  92. package/runtime/python/okstra_ctl/entrypoints/__init__.py +1 -0
  93. package/runtime/python/okstra_ctl/entrypoints/hosts.py +334 -0
  94. package/runtime/python/okstra_ctl/incremental_scope.py +16 -4
  95. package/runtime/python/okstra_ctl/models.py +54 -269
  96. package/runtime/python/okstra_ctl/ports/__init__.py +15 -0
  97. package/runtime/python/okstra_ctl/ports/host.py +32 -0
  98. package/runtime/python/okstra_ctl/ports/interaction.py +15 -0
  99. package/runtime/python/okstra_ctl/ports/lead_session.py +25 -0
  100. package/runtime/python/okstra_ctl/ports/usage_accounting.py +23 -0
  101. package/runtime/python/okstra_ctl/ports/worker_dispatch.py +32 -0
  102. package/runtime/python/okstra_ctl/registry/__init__.py +13 -0
  103. package/runtime/python/okstra_ctl/registry/factory_loader.py +32 -0
  104. package/runtime/python/okstra_ctl/registry/host_discovery.py +124 -0
  105. package/runtime/python/okstra_ctl/registry/host_registry.py +365 -0
  106. package/runtime/python/okstra_ctl/registry/provider_registry.py +149 -0
  107. package/runtime/python/okstra_ctl/render.py +145 -47
  108. package/runtime/python/okstra_ctl/report_html/common.py +71 -25
  109. package/runtime/python/okstra_ctl/report_html/models.py +5 -0
  110. package/runtime/python/okstra_ctl/report_html/render.py +1 -1
  111. package/runtime/python/okstra_ctl/report_html/run_usage.py +19 -0
  112. package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +14 -0
  113. package/runtime/python/okstra_ctl/report_views.py +44 -16
  114. package/runtime/python/okstra_ctl/run.py +80 -58
  115. package/runtime/python/okstra_ctl/session.py +1 -1
  116. package/runtime/python/okstra_ctl/stage_citations.py +52 -15
  117. package/runtime/python/okstra_ctl/team.py +44 -32
  118. package/runtime/python/okstra_ctl/user_response.py +45 -29
  119. package/runtime/python/okstra_ctl/wizard.py +175 -73
  120. package/runtime/python/okstra_ctl/worker_audit_ledger.py +29 -4
  121. package/runtime/python/okstra_ctl/worker_prompt_policy.py +10 -3
  122. package/runtime/python/okstra_ctl/worker_request.py +140 -0
  123. package/runtime/python/okstra_ctl/worker_runner.py +622 -0
  124. package/runtime/python/okstra_token_usage/collect.py +42 -7
  125. package/runtime/python/okstra_token_usage/report.py +42 -0
  126. package/runtime/python/okstra_token_usage/task_totals.py +88 -0
  127. package/runtime/schemas/final-report-v1.0.schema.json +4040 -1066
  128. package/runtime/schemas/final-report-v2.0.schema.json +5673 -1412
  129. package/runtime/skills/okstra-inspect/SKILL.md +1 -2
  130. package/runtime/skills/okstra-inspect/facets/logs.md +5 -5
  131. package/runtime/skills/okstra-inspect/facets/run-audit.md +3 -3
  132. package/runtime/skills/okstra-run/SKILL.md +74 -29
  133. package/runtime/skills/okstra-user-response/SKILL.md +15 -5
  134. package/runtime/templates/implementation-worker-preamble.md +1 -1
  135. package/runtime/templates/report-writer-prompt-preamble.md +1 -0
  136. package/runtime/templates/reports/final-report.template.md +3 -3
  137. package/runtime/templates/reports/html/assets/base.css +8 -4
  138. package/runtime/templates/reports/html/base.template.html +12 -6
  139. package/runtime/templates/reports/html/i18n/en.json +32 -7
  140. package/runtime/templates/reports/html/i18n/ko.json +32 -7
  141. package/runtime/templates/reports/html/macros/forms.html +9 -3
  142. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +14 -19
  143. package/runtime/templates/reports/report.js +59 -26
  144. package/runtime/templates/reports/user-response.template.md +12 -8
  145. package/runtime/templates/worker-prompt-preamble.md +1 -1
  146. package/runtime/validators/validate-implementation-plan-stages.py +17 -22
  147. package/runtime/validators/validate-report-views.py +0 -39
  148. package/runtime/validators/validate-run.py +96 -14
  149. package/runtime/validators/validate_session_conformance.py +69 -3
  150. package/src/cli-registry.mjs +0 -7
  151. package/src/commands/execute/render-bundle.mjs +4 -4
  152. package/src/commands/execute/run.mjs +8 -25
  153. package/src/commands/execute/wizard.mjs +33 -13
  154. package/src/commands/lifecycle/doctor.mjs +10 -10
  155. package/src/commands/lifecycle/install.mjs +53 -30
  156. package/src/commands/lifecycle/preflight.mjs +14 -4
  157. package/src/lib/host-registry-client.mjs +176 -0
  158. package/src/lib/runtime-manifest.mjs +6 -8
  159. package/runtime/bin/okstra-wrapper-agy-stream.py +0 -61
  160. package/runtime/python/okstra_ctl/error_issue.py +0 -640
  161. package/runtime/python/okstra_ctl/issue_signals.py +0 -186
  162. package/runtime/python/okstra_ctl/lead_runtime.py +0 -115
  163. package/runtime/python/okstra_ctl/runner_resolution.py +0 -103
  164. package/runtime/skills/okstra-inspect/facets/error-issue.md +0 -77
  165. package/src/commands/inspect/error-issue.mjs +0 -27
  166. package/src/lib/runtime-readiness.mjs +0 -90
  167. package/src/lib/runtime-resolver.mjs +0 -123
@@ -1,121 +1,184 @@
1
1
  #!/usr/bin/env python3
2
- """Run an external LLM CLI with the shared okstra wrapper contract."""
2
+ """Run an external LLM CLI with the shared okstra wrapper contract.
3
+
4
+ Argument parsing and provider lookup only — the run itself belongs to
5
+ ``okstra_ctl.worker_runner``, which every provider shares. What stays here is
6
+ the request the provider strategies then take on trust: resolved paths, the
7
+ write scope in the order the CLIs are told it, and the role's idle budget.
8
+ """
3
9
  from __future__ import annotations
4
10
 
5
- import json
6
11
  import os
7
- import selectors
8
12
  import shutil
9
- import signal
10
- import subprocess
11
13
  import sys
12
- import time
13
14
  from dataclasses import dataclass
14
15
  from pathlib import Path
15
- from typing import Callable
16
+ from typing import Any, Mapping
17
+
18
+ _HERE = Path(__file__).resolve().parent
19
+ # ``okstra_ctl`` sits beside this file in the repo (``scripts/``) but under
20
+ # ``~/.okstra/lib/python/`` once installed, and the four-line shell entrypoints
21
+ # that exec this script set no PYTHONPATH. Offer both, repo first.
22
+ _HOME_LIB = (
23
+ Path(os.environ.get("OKSTRA_HOME", str(Path.home() / ".okstra"))) / "lib" / "python"
24
+ )
25
+ sys.path.insert(0, str(_HERE))
26
+ if _HOME_LIB.is_dir() and str(_HOME_LIB) not in sys.path:
27
+ sys.path.append(str(_HOME_LIB))
28
+
29
+ from okstra_ctl.domain.provider import ProviderSpec, UnknownProviderError # noqa: E402
30
+ from okstra_ctl.domain.worker_exec import ( # noqa: E402
31
+ ExecutionStrategy,
32
+ WorkerExecRequest,
33
+ )
34
+ from okstra_ctl.registry.provider_registry import ( # noqa: E402
35
+ default_provider_registry,
36
+ )
37
+ from okstra_ctl.worker_request import build_request, idle_timeout # noqa: E402
38
+ from okstra_ctl.worker_runner import LIVE, QUIET, run_worker # noqa: E402
39
+
40
+ _USAGE = (
41
+ "usage: okstra-provider-exec.py <provider> <project-root> "
42
+ "<model-execution-value> <prompt-path> [worktree-path] [role] "
43
+ "[idle-timeout-seconds] [--presentation live|quiet]"
44
+ )
45
+
46
+ _PRESENTATION_FLAG = "--presentation"
47
+ _PRESENTATIONS = (LIVE, QUIET)
48
+
49
+
50
+ class PreflightError(Exception):
51
+ def __init__(self, exit_code: int, message: str) -> None:
52
+ super().__init__(message)
53
+ self.exit_code = exit_code
16
54
 
17
55
 
18
56
  @dataclass(frozen=True)
19
- class ProviderCommand:
20
- binary: str
21
- wrapper: str
22
- build_args: Callable[[str, str, str], list[str]]
57
+ class Invocation:
58
+ strategy: ExecutionStrategy
59
+ request: WorkerExecRequest
60
+ presentation: str
61
+ log_path: Path
62
+ status_path: Path
63
+ status_extra: Mapping[str, Any]
64
+
65
+
66
+ def parse_invocation(argv: list[str]) -> Invocation:
67
+ """Resolve the wrapper's positional contract into one runnable dispatch."""
68
+ positional, presentation = _take_presentation(argv)
69
+ if not 4 <= len(positional) <= 7:
70
+ raise PreflightError(64, _USAGE)
71
+ provider_id, project_root_raw, model, prompt_raw = positional[:4]
72
+ worktree_raw = positional[4] if len(positional) >= 5 else ""
73
+ role = positional[5] if len(positional) >= 6 and positional[5] else "worker"
74
+ timeout_raw = positional[6] if len(positional) >= 7 and positional[6] else ""
75
+
76
+ spec = _provider_spec(provider_id)
77
+ project_root = _existing_dir(project_root_raw, 65, "project-root")
78
+ if not model:
79
+ raise PreflightError(66, "model-execution-value is empty")
80
+ prompt_path = _existing_file(prompt_raw, 67, "prompt-path")
81
+ idle_timeout_seconds = _idle_timeout(timeout_raw, role)
82
+ worktree = (
83
+ _existing_dir(worktree_raw, 68, "worktree-path") if worktree_raw else None
84
+ )
23
85
 
86
+ request = build_request(
87
+ prompt_text=prompt_path.read_text(encoding="utf-8"),
88
+ model=model,
89
+ project_root=project_root,
90
+ worktree_path=worktree,
91
+ role=role,
92
+ idle_timeout_seconds=idle_timeout_seconds,
93
+ )
94
+ strategy = spec.exec_strategy
95
+ _check_command(strategy, request)
96
+ return Invocation(
97
+ strategy=strategy,
98
+ request=request,
99
+ presentation=presentation,
100
+ log_path=_log_path(prompt_path),
101
+ status_path=Path(f"{prompt_path}.status.json"),
102
+ status_extra={"wrapper": spec.wrapper, "role": role},
103
+ )
24
104
 
25
- def _grok_args(prompt: str, model: str, cwd: str) -> list[str]:
26
- return [
27
- "grok",
28
- "-p",
29
- prompt,
30
- "-m",
31
- model,
32
- "--output-format",
33
- "streaming-json",
34
- "--cwd",
35
- cwd,
36
- ]
37
105
 
106
+ def _take_presentation(argv: list[str]) -> tuple[list[str], str]:
107
+ """Split the one flag out of an otherwise positional argv.
108
+
109
+ Defaults to ``quiet``. ``live`` is only ever right where a screen was
110
+ declared, and the only callers that can declare one are the pane backends —
111
+ which pass the flag explicitly. Defaulting the other way assumed a screen
112
+ that a subagent dispatch does not have, and sent every worker's progress
113
+ into its caller's context window instead.
114
+ """
115
+ positional: list[str] = []
116
+ presentation = QUIET
117
+ index = 0
118
+ while index < len(argv):
119
+ if argv[index] != _PRESENTATION_FLAG:
120
+ positional.append(argv[index])
121
+ index += 1
122
+ continue
123
+ if index + 1 >= len(argv):
124
+ raise PreflightError(64, f"{_PRESENTATION_FLAG} needs a value: {_USAGE}")
125
+ presentation = argv[index + 1]
126
+ index += 2
127
+ if presentation not in _PRESENTATIONS:
128
+ allowed = " | ".join(_PRESENTATIONS)
129
+ raise PreflightError(
130
+ 64, f"unsupported presentation {presentation!r}. Allowed values: {allowed}"
131
+ )
132
+ return positional, presentation
38
133
 
39
- def _kimi_args(prompt: str, model: str, _cwd: str) -> list[str]:
40
- return ["kimi", "-p", prompt, "-m", model, "--output-format", "stream-json"]
41
134
 
135
+ def _provider_spec(provider_id: str) -> ProviderSpec:
136
+ try:
137
+ spec = default_provider_registry().resolve(provider_id)
138
+ except UnknownProviderError as exc:
139
+ raise PreflightError(64, str(exc)) from exc
140
+ if spec.exec_strategy is None:
141
+ raise PreflightError(
142
+ 64, f"provider {provider_id!r} has no execution strategy to run"
143
+ )
144
+ return spec
42
145
 
43
- PROVIDERS = {
44
- "grok": ProviderCommand("grok", "okstra-grok-exec.sh", _grok_args),
45
- "kimi": ProviderCommand("kimi", "okstra-kimi-exec.sh", _kimi_args),
46
- }
47
146
 
147
+ def _existing_dir(raw: str, exit_code: int, label: str) -> Path:
148
+ """Existence only — `build_request` owns the resolving."""
149
+ path = Path(raw) if raw else None
150
+ if path is None or not path.is_dir():
151
+ raise PreflightError(
152
+ exit_code, f"{label} is missing or not a directory: {raw!r}"
153
+ )
154
+ return path
48
155
 
49
- @dataclass(frozen=True)
50
- class Invocation:
51
- provider: ProviderCommand
52
- project_root: Path
53
- model: str
54
- prompt_path: Path
55
- execution_root: Path
56
- role: str
57
- idle_timeout_seconds: int
58
156
 
157
+ def _existing_file(raw: str, exit_code: int, label: str) -> Path:
158
+ path = Path(raw) if raw else None
159
+ if path is None or not path.is_file():
160
+ raise PreflightError(exit_code, f"{label} is missing or not a file: {raw!r}")
161
+ return path.resolve()
59
162
 
60
- class PreflightError(Exception):
61
- def __init__(self, exit_code: int, message: str) -> None:
62
- super().__init__(message)
63
- self.exit_code = exit_code
64
163
 
164
+ def _idle_timeout(raw: str, role: str) -> int:
165
+ try:
166
+ return idle_timeout(raw, role)
167
+ except ValueError as exc:
168
+ raise PreflightError(69, str(exc)) from exc
65
169
 
66
- def _parse_invocation(argv: list[str]) -> Invocation:
67
- if len(argv) < 4 or len(argv) > 7:
68
- raise PreflightError(
69
- 64,
70
- "usage: okstra-provider-exec.py <provider> <project-root> <model-execution-value> "
71
- "<prompt-path> [worktree-path] [role] [idle-timeout-seconds]",
72
- )
73
- provider_id, project_root_raw, model, prompt_raw = argv[:4]
74
- provider = PROVIDERS.get(provider_id)
75
- if provider is None:
76
- raise PreflightError(64, f"unsupported provider: {provider_id}")
77
- worktree_raw = argv[4] if len(argv) >= 5 else ""
78
- role = argv[5] if len(argv) >= 6 and argv[5] else "worker"
79
- default_timeout = 1500 if role in {"executor", "verifier"} else 600
80
- timeout_raw = argv[6] if len(argv) >= 7 else str(default_timeout)
81
- return _validate_invocation(
82
- provider, project_root_raw, model, prompt_raw, worktree_raw, role, timeout_raw
83
- )
84
170
 
171
+ def _check_command(strategy: ExecutionStrategy, request: WorkerExecRequest) -> None:
172
+ """Refuse a missing CLI before the run leaves any artifact behind.
85
173
 
86
- def _validate_invocation(
87
- provider: ProviderCommand,
88
- project_root_raw: str,
89
- model: str,
90
- prompt_raw: str,
91
- worktree_raw: str,
92
- role: str,
93
- timeout_raw: str,
94
- ) -> Invocation:
95
- project_root = Path(project_root_raw)
96
- prompt_path = Path(prompt_raw)
97
- if not project_root_raw or not project_root.is_dir():
98
- raise PreflightError(65, f"project-root is missing or not a directory: {project_root_raw!r}")
99
- if not model:
100
- raise PreflightError(66, "model-execution-value is empty")
101
- if not prompt_raw or not prompt_path.is_file():
102
- raise PreflightError(67, f"prompt-path is missing or not a file: {prompt_raw!r}")
103
- if not timeout_raw.isdigit():
104
- raise PreflightError(69, f"idle-timeout-seconds must be a non-negative integer: {timeout_raw!r}")
105
- execution_root = Path(worktree_raw) if worktree_raw else project_root
106
- if worktree_raw and not execution_root.is_dir():
107
- raise PreflightError(68, f"worktree-path was provided but is not a directory: {worktree_raw!r}")
108
- if shutil.which(provider.binary) is None:
109
- raise PreflightError(127, f"{provider.binary} CLI is not installed on PATH")
110
- return Invocation(
111
- provider=provider,
112
- project_root=project_root.resolve(),
113
- model=model,
114
- prompt_path=prompt_path.resolve(),
115
- execution_root=execution_root.resolve(),
116
- role=role,
117
- idle_timeout_seconds=int(timeout_raw),
118
- )
174
+ Building the command is the only truthful way to learn which binary this
175
+ provider runs, and it is a pure call. The check stays out of the runner so a
176
+ refused dispatch leaves no `started` status sidecar for the liveness probe to
177
+ read as a worker that launched.
178
+ """
179
+ binary = strategy.build_command(request).argv[0]
180
+ if shutil.which(binary) is None:
181
+ raise PreflightError(127, f"{binary} CLI is not installed on PATH")
119
182
 
120
183
 
121
184
  def _log_path(prompt_path: Path) -> Path:
@@ -124,105 +187,17 @@ def _log_path(prompt_path: Path) -> Path:
124
187
  return Path(f"{prompt_path}.log")
125
188
 
126
189
 
127
- def _write_status(path: Path, status: dict[str, object]) -> None:
128
- temporary = Path(f"{path}.tmp")
129
- temporary.write_text(json.dumps(status, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
130
- os.replace(temporary, path)
131
-
132
-
133
- def _terminate_process(process: subprocess.Popen[bytes]) -> None:
134
- try:
135
- os.killpg(process.pid, signal.SIGTERM)
136
- except ProcessLookupError:
137
- return
138
- try:
139
- process.wait(timeout=5)
140
- except subprocess.TimeoutExpired:
141
- try:
142
- os.killpg(process.pid, signal.SIGKILL)
143
- except ProcessLookupError:
144
- pass
145
- process.wait()
146
-
147
-
148
- def _stream_process(
149
- process: subprocess.Popen[bytes], log_file, idle_timeout_seconds: int
150
- ) -> tuple[int, bool, int]:
151
- selector = selectors.DefaultSelector()
152
- assert process.stdout is not None
153
- selector.register(process.stdout, selectors.EVENT_READ)
154
- last_output = time.monotonic()
155
- timed_out = False
156
- idle_seconds = 0
157
- while selector.get_map():
158
- for key, _ in selector.select(timeout=0.25):
159
- chunk = os.read(key.fd, 8192)
160
- if not chunk:
161
- selector.unregister(key.fileobj)
162
- continue
163
- last_output = time.monotonic()
164
- sys.stdout.buffer.write(chunk)
165
- sys.stdout.buffer.flush()
166
- log_file.write(chunk)
167
- log_file.flush()
168
- idle_seconds = int(time.monotonic() - last_output)
169
- if idle_timeout_seconds and idle_seconds >= idle_timeout_seconds and process.poll() is None:
170
- timed_out = True
171
- _terminate_process(process)
172
- exit_code = process.wait()
173
- return (124 if timed_out else exit_code), timed_out, idle_seconds
174
-
175
-
176
- def _run(invocation: Invocation) -> int:
177
- prompt = invocation.prompt_path.read_text(encoding="utf-8")
178
- command = invocation.provider.build_args(prompt, invocation.model, str(invocation.execution_root))
179
- status_path = Path(f"{invocation.prompt_path}.status.json")
180
- log_path = _log_path(invocation.prompt_path)
181
- started_ts = int(time.time())
182
- started_monotonic = time.monotonic()
183
- status: dict[str, object] = {
184
- "schemaVersion": 1,
185
- "wrapper": invocation.provider.wrapper,
186
- "role": invocation.role,
187
- "pid": os.getpid(),
188
- "started_ts": started_ts,
189
- "log_path": str(log_path),
190
- "stage": "started",
191
- }
192
- _write_status(status_path, status)
193
- with log_path.open("wb") as log_file:
194
- process = subprocess.Popen(
195
- command,
196
- cwd=invocation.execution_root,
197
- stdout=subprocess.PIPE,
198
- stderr=subprocess.STDOUT,
199
- start_new_session=True,
200
- )
201
- exit_code, timed_out, idle_seconds = _stream_process(
202
- process, log_file, invocation.idle_timeout_seconds
203
- )
204
- ended_ts = int(time.time())
205
- status.update(
206
- stage="exited",
207
- exit_code=exit_code,
208
- ended_ts=ended_ts,
209
- duration_ms=int((time.monotonic() - started_monotonic) * 1000),
210
- )
211
- if timed_out:
212
- status.update(
213
- timeout=True,
214
- idle_at_ts=ended_ts,
215
- idle_seconds=idle_seconds,
216
- terminated_by="idle-watchdog",
217
- )
218
- _write_status(status_path, status)
219
- return exit_code
220
-
221
-
222
190
  def main(argv: list[str]) -> int:
223
191
  try:
224
- invocation = _parse_invocation(argv[1:])
225
- return _run(invocation)
192
+ invocation = parse_invocation(argv[1:])
193
+ return run_worker(
194
+ invocation.strategy,
195
+ invocation.request,
196
+ presentation=invocation.presentation,
197
+ log_path=invocation.log_path,
198
+ status_path=invocation.status_path,
199
+ status_extra=invocation.status_extra,
200
+ )
226
201
  except PreflightError as exc:
227
202
  print(f"okstra-provider-exec: {exc}", file=sys.stderr)
228
203
  return exc.exit_code
@@ -2,13 +2,20 @@
2
2
  #
3
3
  # okstra-trace-cleanup.sh — close tmux panes created during okstra runs.
4
4
  #
5
- # Trace panes are `tail -F` siblings spawned by the codex/antigravity wrappers
6
- # (`okstra-codex-exec.sh`, `okstra-antigravity-exec.sh`). Worker-compute panes are
7
- # tmux-pane backend siblings. Each wrapper/dispatcher tags the pane it owns with
8
- # a pane-level user option (`@okstra_trace_run=<RUN_DIR>` or
9
- # `@okstra_worker_run=<RUN_DIR>`), so panes are found server-wide by tag — no
10
- # tmux env var or pane-id registry is needed, and the run-scoped tag keeps
11
- # concurrent okstra runs from closing each other's panes.
5
+ # Worker-compute panes are tmux-pane backend siblings. Their dispatcher tags
6
+ # each pane it owns with a pane-level user option (`@okstra_worker_run=<RUN_DIR>`),
7
+ # so panes are found server-wide by tag — no tmux env var or pane-id registry is
8
+ # needed, and the run-scoped tag keeps concurrent okstra runs from closing each
9
+ # other's panes.
10
+ #
11
+ # Trace panes were `tail -F` siblings the provider wrappers split, tagged
12
+ # `@okstra_trace_run` / `@okstra_status`. Those wrappers are now four-line
13
+ # entrypoints and worker progress renders into the worker's own pane, so
14
+ # NOTHING SPAWNS A TRACE PANE and neither tag has a writer left. The trace
15
+ # paths below still run and simply match nothing; `--reclaim-completed`, which
16
+ # keys on `@okstra_status`, is inert for the same reason. Kept rather than
17
+ # deleted because the hooks that call this script are already seeded on user
18
+ # machines — retiring the trace machinery is a deliberate follow-up.
12
19
  #
13
20
  # Two invocation shapes:
14
21
  #
@@ -1,27 +1,34 @@
1
1
  #!/usr/bin/env python3
2
- """okstra-wrapper-status.py — heartbeat sidecar writer for codex/antigravity wrappers.
3
-
4
- The codex/antigravity wrappers (`okstra-codex-exec.sh`, `okstra-antigravity-exec.sh`)
5
- dispatch a long-running CLI under `Bash(run_in_background: true)` and rely on
6
- `BashOutput` polling for liveness. That polling stream only carries stdout
7
- plus a binary `running`/`completed` state. Several recovery decisions need
8
- more — specifically, "did this wrapper start at all, when, and how did it
9
- finish?" — so the wrappers write a small JSON sidecar at
10
- `<prompt-path>.status.json` that survives independent of the polling channel.
11
-
12
- Consumers:
13
-
14
- * `codex-worker` / `antigravity-worker` step 8c: read `log_path` to capture a
15
- diagnostic tail when `exit_code == 0` but the canonical Result file is
16
- absent.
17
- * Lead: cross-check `started_ts` / `ended_ts` to distinguish "wrapper hung
2
+ """okstra-wrapper-status.py — standalone CLI for the worker status sidecar.
3
+
4
+ A CLI worker dispatch runs a long-running CLI in the background, and the
5
+ polling channel that watches it carries only stdout plus a binary
6
+ `running`/`completed` state. Several recovery decisions need more —
7
+ specifically, "did this worker start at all, when, and how did it finish?" — so
8
+ a small JSON sidecar is written at `<prompt-path>.status.json` that survives
9
+ independent of the polling channel.
10
+
11
+ **Nothing calls this script.** Every provider entrypoint now runs through
12
+ `scripts/okstra_ctl/worker_runner.py`, which writes the same document
13
+ in-process; the shell wrappers that shelled out to this file were its only
14
+ callers and they are gone. It is kept for now rather than deleted because
15
+ removing it also means touching the build sync list, the install payload, its
16
+ own test module and the docs that name it — a deliberate follow-up, not a side
17
+ effect of the wrapper migration. `scripts/okstra_ctl/wrapper_status.py` is the
18
+ reader, and what it reads is what the runner writes.
19
+
20
+ Consumers of the sidecar:
21
+
22
+ * A CLI worker's diagnostic step: read `log_path` to capture a tail when
23
+ `exit_code == 0` but the canonical Result file is absent.
24
+ * Lead: cross-check `started_ts` / `ended_ts` to distinguish "worker hung
18
25
  before CLI launched" from "CLI finished but never wrote artifact" when
19
26
  applying the redispatch policy (see team-contract "Lead Redispatch
20
27
  Policy on Result-Missing").
21
28
 
22
- Failures are deliberately non-fatal for the caller — the wrapper's main
23
- job is to run the underlying CLI; a missing sidecar must not break that.
24
- On any error the script prints a one-line diagnostic to stderr and exits 0.
29
+ Failures are deliberately non-fatal for the caller — the caller's main job is
30
+ to run the underlying CLI; a missing sidecar must not break that. On any error
31
+ the script prints a one-line diagnostic to stderr and exits 0.
25
32
 
26
33
  Schema (schemaVersion 1):
27
34
 
@@ -58,10 +58,87 @@ okstra_py() {
58
58
  PYTHONPATH="$OKSTRA_PYTHONPATH:${PYTHONPATH-}" python3 "$@"
59
59
  }
60
60
 
61
+ confirm_sandbox_waiver() {
62
+ local sandbox_note="$1"
63
+ local lead_executable="$2"
64
+ local cancel_message="$3"
65
+ local assume_yes_message="$4"
66
+ local sandbox_answer
67
+
68
+ [[ -z "$sandbox_note" ]] && return
69
+ printf '\n%s\n' "$sandbox_note" >&2
70
+ if [[ "$ASSUME_YES" == "true" ]]; then
71
+ [[ -n "$assume_yes_message" ]] && printf '%s\n\n' "$assume_yes_message" >&2
72
+ return 0
73
+ fi
74
+ printf 'Start %s without its sandbox? [y/N] ' "$lead_executable" >&2
75
+ read -r sandbox_answer < /dev/tty || sandbox_answer=""
76
+ if [[ "$sandbox_answer" != "y" && "$sandbox_answer" != "Y" ]]; then
77
+ printf '%s\n' "$cancel_message" >&2
78
+ exit 1
79
+ fi
80
+ }
81
+
82
+ execute_launch_plan() {
83
+ local cancel_message="$1"
84
+ local assume_yes_message="$2"
85
+ shift 2
86
+ local launch_plan_file
87
+ local sandbox_note
88
+ local env_count
89
+ local argv_offset
90
+ local lead_executable
91
+ local field
92
+ local -a launch_fields
93
+ local -a launch_env
94
+ local -a launch_argv
95
+
96
+ launch_plan_file="$(mktemp "${TMPDIR:-/tmp}/okstra-launch-plan.XXXXXX")"
97
+ if ! okstra_py -m okstra_ctl.entrypoints.hosts "$@" \
98
+ --format null > "$launch_plan_file"; then
99
+ rm -f "$launch_plan_file"
100
+ exit 1
101
+ fi
102
+ launch_fields=()
103
+ while IFS= read -r -d '' field; do
104
+ launch_fields+=("$field")
105
+ done < "$launch_plan_file"
106
+ rm -f "$launch_plan_file"
107
+ if [[ ${#launch_fields[@]} -lt 4 ]] || \
108
+ [[ ! "${launch_fields[2]}" =~ ^[0-9]+$ ]]; then
109
+ printf 'okstra: invalid launch plan\n' >&2
110
+ exit 1
111
+ fi
112
+ sandbox_note="${launch_fields[1]}"
113
+ env_count="${launch_fields[2]}"
114
+ argv_offset=$((env_count + 3))
115
+ if [[ ${#launch_fields[@]} -le $argv_offset ]]; then
116
+ printf 'okstra: invalid launch plan\n' >&2
117
+ exit 1
118
+ fi
119
+ launch_env=("${launch_fields[@]:3:env_count}")
120
+ launch_argv=("${launch_fields[@]:argv_offset}")
121
+ lead_executable="${launch_argv[0]}"
122
+ confirm_sandbox_waiver \
123
+ "$sandbox_note" "$lead_executable" "$cancel_message" "$assume_yes_message"
124
+ cd "${launch_fields[0]}"
125
+ exec env "${launch_env[@]}" "${launch_argv[@]}"
126
+ }
127
+
61
128
  parse_cli_arguments "$@"
62
129
  split_task_key
63
130
 
64
131
  PROJECT_ROOT="$(resolve_project_root_safe "$PROJECT_ROOT_OVERRIDE")"
132
+ if [[ "$RESUME_SESSION_MODE" == "true" ]]; then
133
+ if [[ -z "$PROJECT_ROOT" ]]; then
134
+ PROJECT_ROOT="$(resolve_project_root_strict "$PROJECT_ROOT_OVERRIDE")" || exit 1
135
+ fi
136
+ execute_launch_plan \
137
+ "okstra: resume cancelled" \
138
+ "" \
139
+ resume-plan --host "$LEAD_RUNTIME" --entry-mode spawn-process \
140
+ --project-root "$PROJECT_ROOT" --session-id "$RESUME_SESSION_ID"
141
+ fi
65
142
  if [[ "$RESUME_CLARIFICATION_MODE" == "true" ]]; then
66
143
  if [[ -z "$PROJECT_ROOT" ]]; then
67
144
  PROJECT_ROOT="$(resolve_project_root_strict "$PROJECT_ROOT_OVERRIDE")" \
@@ -80,47 +157,11 @@ if [[ "$LAUNCH_ONLY" == "true" ]]; then
80
157
  if [[ -z "$PROJECT_ROOT" ]]; then
81
158
  PROJECT_ROOT="$(resolve_project_root_strict "$PROJECT_ROOT_OVERRIDE")" || exit 1
82
159
  fi
83
- read -r LEAD_EXECUTABLE SANDBOX_NOTE < <(
84
- okstra_py - "$LEAD_RUNTIME" <<'PY'
85
- import sys
86
- from okstra_ctl.lead_runtime import lead_runtime_info
87
- from okstra_ctl.models import bare_lead_launch_argv, lead_launch_spec
88
- provider = lead_runtime_info(sys.argv[1]).agent
89
- print(bare_lead_launch_argv(provider)[0], lead_launch_spec(provider).sandbox_waiver_note)
90
- PY
91
- )
92
- if ! command -v "$LEAD_EXECUTABLE" >/dev/null 2>&1; then
93
- printf '%s command not found\n' "$LEAD_EXECUTABLE" >&2
94
- exit 1
95
- fi
96
- if [[ -n "$SANDBOX_NOTE" ]]; then
97
- okstra_py - "$LEAD_RUNTIME" <<'PY' >&2
98
- import sys
99
- from okstra_ctl.lead_runtime import lead_runtime_info
100
- from okstra_ctl.models import lead_launch_spec
101
- print("\n" + lead_launch_spec(lead_runtime_info(sys.argv[1]).agent).sandbox_waiver_note)
102
- PY
103
- if [[ "$ASSUME_YES" != "true" ]]; then
104
- printf 'Start %s without its sandbox? [y/N] ' "$LEAD_EXECUTABLE" >&2
105
- read -r _sandbox_answer < /dev/tty || _sandbox_answer=""
106
- if [[ "$_sandbox_answer" != "y" && "$_sandbox_answer" != "Y" ]]; then
107
- printf 'okstra: cancelled\n' >&2
108
- exit 1
109
- fi
110
- fi
111
- fi
112
- LAUNCH_ARGV=()
113
- while IFS= read -r -d '' _arg; do LAUNCH_ARGV+=("$_arg"); done < <(
114
- okstra_py - "$LEAD_RUNTIME" <<'PY'
115
- import sys
116
- from okstra_ctl.lead_runtime import lead_runtime_info
117
- from okstra_ctl.models import bare_lead_launch_argv
118
- for a in bare_lead_launch_argv(lead_runtime_info(sys.argv[1]).agent):
119
- sys.stdout.write(a + "\0")
120
- PY
121
- )
122
- cd "$PROJECT_ROOT"
123
- exec "${LAUNCH_ARGV[@]}"
160
+ execute_launch_plan \
161
+ "okstra: cancelled" \
162
+ "" \
163
+ launch-plan --host "$LEAD_RUNTIME" --entry-mode spawn-process \
164
+ --project-root "$PROJECT_ROOT"
124
165
  fi
125
166
 
126
167
  autofill_from_manifest
@@ -232,57 +273,12 @@ if [[ -z "$LAUNCH_JSON" ]]; then
232
273
  exit 1
233
274
  fi
234
275
 
235
- read -r LEAD_EXECUTABLE PROJECT_ROOT_FROM_PY < <(
236
- okstra_py - "$LAUNCH_JSON" <<'PY'
237
- import json, sys
238
- d = json.loads(sys.argv[1])
239
- print(d["leadExecutable"], d["projectRoot"])
240
- PY
241
- )
242
-
243
- if ! command -v "$LEAD_EXECUTABLE" >/dev/null 2>&1; then
244
- printf '%s command not found\n' "$LEAD_EXECUTABLE" >&2
245
- exit 1
246
- fi
247
-
248
- # Starting some leads means lowering a protection that CLI applies to itself.
249
- # okstra never waives it silently: the note says what is given up, and nothing
250
- # starts without an explicit yes.
251
- SANDBOX_NOTE="$(
252
- okstra_py - "$LAUNCH_JSON" <<'PY'
253
- import json, sys
254
- print(json.loads(sys.argv[1])["sandboxWaiverNote"])
255
- PY
256
- )"
257
- if [[ -n "$SANDBOX_NOTE" ]]; then
258
- printf '\n%s\n' "$SANDBOX_NOTE" >&2
259
- if [[ "$ASSUME_YES" == "true" ]]; then
260
- printf 'Proceeding without the sandbox (--yes).\n\n' >&2
261
- else
262
- printf 'Start %s without its sandbox? [y/N] ' "$LEAD_EXECUTABLE" >&2
263
- read -r SANDBOX_ANSWER < /dev/tty || SANDBOX_ANSWER=""
264
- if [[ "$SANDBOX_ANSWER" != "y" && "$SANDBOX_ANSWER" != "Y" ]]; then
265
- printf 'okstra: cancelled. The task bundle is prepared; rerun to resume it.\n' >&2
266
- exit 1
267
- fi
268
- fi
269
- fi
270
-
271
- # NUL-separated so a prompt carrying newlines or quotes survives the handoff.
272
- LAUNCH_ARGV=()
273
- while IFS= read -r -d '' LAUNCH_ARG; do
274
- LAUNCH_ARGV+=("$LAUNCH_ARG")
275
- done < <(
276
- okstra_py - "$LAUNCH_JSON" <<'PY'
277
- import json, sys
278
- for arg in json.loads(sys.argv[1])["launchArgv"]:
279
- sys.stdout.write(arg + "\0")
280
- PY
281
- )
282
-
283
276
  # Note: per-session --settings injection was removed. okstra-ctl prepare
284
277
  # provisions <PROJECT_ROOT>/.claude/settings.local.json as a symlink to
285
278
  # ~/.okstra/templates/settings.local.json, which Claude Code auto-loads
286
279
  # whenever it runs inside that project — no CLI flag required.
287
- cd "$PROJECT_ROOT_FROM_PY"
288
- exec "${LAUNCH_ARGV[@]}"
280
+ execute_launch_plan \
281
+ "okstra: cancelled. The task bundle is prepared; rerun to resume it." \
282
+ "Proceeding without the sandbox (--yes)." \
283
+ launch-plan --host "$LEAD_RUNTIME" --entry-mode spawn-process \
284
+ --prepared-launch-json "$LAUNCH_JSON"