okstra 0.163.2 → 0.164.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 (112) hide show
  1. package/README.md +7 -5
  2. package/docs/architecture.md +12 -7
  3. package/docs/cli.md +8 -5
  4. package/docs/project-structure-overview.md +7 -5
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/agents/workers/claude-worker.md +1 -0
  8. package/runtime/bin/lib/okstra/cli.sh +5 -0
  9. package/runtime/bin/lib/okstra/globals.sh +2 -0
  10. package/runtime/bin/lib/okstra/usage.sh +5 -5
  11. package/runtime/bin/okstra.sh +87 -91
  12. package/runtime/prompts/lead/adapters/cmux.md +1 -1
  13. package/runtime/prompts/lead/okstra-lead-contract.md +1 -0
  14. package/runtime/prompts/profiles/implementation-planning.md +1 -1
  15. package/runtime/python/okstra_ctl/adapters/accounting/__init__.py +11 -0
  16. package/runtime/python/okstra_ctl/adapters/accounting/claude_jsonl.py +17 -0
  17. package/runtime/python/okstra_ctl/adapters/accounting/cli_artifact.py +17 -0
  18. package/runtime/python/okstra_ctl/adapters/accounting/unavailable.py +19 -0
  19. package/runtime/python/okstra_ctl/adapters/dispatch/__init__.py +92 -0
  20. package/runtime/python/okstra_ctl/adapters/dispatch/cli_wrapper.py +54 -0
  21. package/runtime/python/okstra_ctl/adapters/dispatch/cmux.py +68 -0
  22. package/runtime/python/okstra_ctl/adapters/dispatch/native_team.py +13 -0
  23. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/adapter.py +60 -0
  24. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/manifest.json +1 -0
  25. package/runtime/{prompts/lead/adapters/antigravity.md → python/okstra_ctl/adapters/hosts/antigravity/relay.md} +52 -0
  26. package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +292 -0
  27. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +120 -0
  28. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/manifest.json +1 -0
  29. package/runtime/{prompts/lead/adapters/claude-code.md → python/okstra_ctl/adapters/hosts/claude-code/relay.md} +111 -0
  30. package/runtime/python/okstra_ctl/adapters/hosts/codex/adapter.py +60 -0
  31. package/runtime/python/okstra_ctl/adapters/hosts/codex/manifest.json +1 -0
  32. package/runtime/{prompts/lead/adapters/codex.md → python/okstra_ctl/adapters/hosts/codex/relay.md} +52 -0
  33. package/runtime/python/okstra_ctl/adapters/hosts/external/adapter.py +72 -0
  34. package/runtime/python/okstra_ctl/adapters/hosts/external/manifest.json +1 -0
  35. package/runtime/{prompts/lead/adapters/external.md → python/okstra_ctl/adapters/hosts/external/relay.md} +52 -0
  36. package/runtime/python/okstra_ctl/adapters/hosts/grok/adapter.py +63 -0
  37. package/runtime/python/okstra_ctl/adapters/hosts/grok/manifest.json +1 -0
  38. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +90 -0
  39. package/runtime/python/okstra_ctl/adapters/hosts/kimi/adapter.py +63 -0
  40. package/runtime/python/okstra_ctl/adapters/hosts/kimi/manifest.json +1 -0
  41. package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +90 -0
  42. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +35 -0
  43. package/runtime/python/okstra_ctl/adapters/providers/antigravity/manifest.json +1 -0
  44. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +55 -0
  45. package/runtime/python/okstra_ctl/adapters/providers/claude/manifest.json +1 -0
  46. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +43 -0
  47. package/runtime/python/okstra_ctl/adapters/providers/codex/manifest.json +1 -0
  48. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +32 -0
  49. package/runtime/python/okstra_ctl/adapters/providers/grok/manifest.json +1 -0
  50. package/runtime/python/okstra_ctl/adapters/providers/kimi/adapter.py +38 -0
  51. package/runtime/python/okstra_ctl/adapters/providers/kimi/manifest.json +1 -0
  52. package/runtime/python/okstra_ctl/application/__init__.py +1 -0
  53. package/runtime/python/okstra_ctl/application/advance_wizard.py +25 -0
  54. package/runtime/python/okstra_ctl/application/collect_usage.py +15 -0
  55. package/runtime/python/okstra_ctl/application/dispatch_assignments.py +15 -0
  56. package/runtime/python/okstra_ctl/application/resolve_assignment.py +93 -0
  57. package/runtime/python/okstra_ctl/application/resume_run.py +21 -0
  58. package/runtime/python/okstra_ctl/application/start_run.py +21 -0
  59. package/runtime/python/okstra_ctl/codex_dispatch.py +49 -826
  60. package/runtime/python/okstra_ctl/dispatch_core.py +240 -29
  61. package/runtime/python/okstra_ctl/dispatch_state.py +17 -0
  62. package/runtime/python/okstra_ctl/domain/__init__.py +34 -0
  63. package/runtime/python/okstra_ctl/domain/host.py +100 -0
  64. package/runtime/python/okstra_ctl/domain/provider.py +66 -0
  65. package/runtime/python/okstra_ctl/domain/wizard/__init__.py +19 -0
  66. package/runtime/python/okstra_ctl/domain/wizard/interaction.py +140 -0
  67. package/runtime/python/okstra_ctl/entrypoints/__init__.py +1 -0
  68. package/runtime/python/okstra_ctl/entrypoints/hosts.py +334 -0
  69. package/runtime/python/okstra_ctl/models.py +54 -269
  70. package/runtime/python/okstra_ctl/ports/__init__.py +15 -0
  71. package/runtime/python/okstra_ctl/ports/host.py +32 -0
  72. package/runtime/python/okstra_ctl/ports/interaction.py +15 -0
  73. package/runtime/python/okstra_ctl/ports/lead_session.py +25 -0
  74. package/runtime/python/okstra_ctl/ports/usage_accounting.py +23 -0
  75. package/runtime/python/okstra_ctl/ports/worker_dispatch.py +32 -0
  76. package/runtime/python/okstra_ctl/registry/__init__.py +13 -0
  77. package/runtime/python/okstra_ctl/registry/factory_loader.py +32 -0
  78. package/runtime/python/okstra_ctl/registry/host_discovery.py +124 -0
  79. package/runtime/python/okstra_ctl/registry/host_registry.py +365 -0
  80. package/runtime/python/okstra_ctl/registry/provider_registry.py +149 -0
  81. package/runtime/python/okstra_ctl/render.py +145 -47
  82. package/runtime/python/okstra_ctl/run.py +80 -58
  83. package/runtime/python/okstra_ctl/session.py +1 -1
  84. package/runtime/python/okstra_ctl/team.py +44 -32
  85. package/runtime/python/okstra_ctl/wizard.py +162 -64
  86. package/runtime/python/okstra_ctl/worker_audit_ledger.py +29 -4
  87. package/runtime/python/okstra_token_usage/collect.py +34 -6
  88. package/runtime/schemas/final-report-v1.0.schema.json +3989 -1085
  89. package/runtime/schemas/final-report-v2.0.schema.json +5622 -1451
  90. package/runtime/skills/okstra-run/SKILL.md +73 -28
  91. package/runtime/templates/implementation-worker-preamble.md +1 -1
  92. package/runtime/templates/reports/final-report.template.md +3 -3
  93. package/runtime/templates/reports/html/i18n/en.json +3 -1
  94. package/runtime/templates/reports/html/i18n/ko.json +3 -1
  95. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +1 -1
  96. package/runtime/templates/worker-prompt-preamble.md +1 -1
  97. package/runtime/validators/validate-implementation-plan-stages.py +17 -22
  98. package/runtime/validators/validate-report-views.py +0 -39
  99. package/runtime/validators/validate-run.py +8 -7
  100. package/runtime/validators/validate_session_conformance.py +7 -2
  101. package/src/commands/execute/render-bundle.mjs +4 -4
  102. package/src/commands/execute/run.mjs +8 -25
  103. package/src/commands/execute/wizard.mjs +33 -13
  104. package/src/commands/lifecycle/doctor.mjs +10 -10
  105. package/src/commands/lifecycle/install.mjs +53 -30
  106. package/src/commands/lifecycle/preflight.mjs +14 -4
  107. package/src/lib/host-registry-client.mjs +176 -0
  108. package/src/lib/runtime-manifest.mjs +6 -8
  109. package/runtime/python/okstra_ctl/lead_runtime.py +0 -115
  110. package/runtime/python/okstra_ctl/runner_resolution.py +0 -103
  111. package/src/lib/runtime-readiness.mjs +0 -90
  112. package/src/lib/runtime-resolver.mjs +0 -123
@@ -0,0 +1,140 @@
1
+ """Host-neutral wizard prompts, interaction plans, and answer protocols."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from dataclasses import dataclass
6
+ from typing import Literal
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class WizardOption:
11
+ value: str
12
+ label: str
13
+ description: str = ""
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class WizardPrompt:
18
+ step: str
19
+ kind: Literal["pick", "text", "pick_group", "done", "aborted"]
20
+ label: str = ""
21
+ options: tuple[WizardOption, ...] = ()
22
+ help: str = ""
23
+ echo_template: str = ""
24
+ multi: bool = False
25
+ questions: tuple["WizardPrompt", ...] = ()
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class AnswerProtocol:
30
+ kind: Literal["exact-value", "numbered", "group-json", "numbered-group-json"]
31
+ multi: bool = False
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class InteractionPlan:
36
+ kind: Literal[
37
+ "native-single",
38
+ "native-multi",
39
+ "native-group",
40
+ "numbered-single",
41
+ "numbered-multi",
42
+ "sequential-group",
43
+ "plain-text",
44
+ ]
45
+ answer_protocol: AnswerProtocol
46
+
47
+
48
+ class WizardAnswerError(ValueError):
49
+ """Raised when an answer violates the interaction plan's protocol."""
50
+
51
+
52
+ def _normalize_numbered_item(prompt: WizardPrompt, answer: str) -> str:
53
+ candidate = (answer or "").strip()
54
+ exact_values = tuple(
55
+ option.value for option in prompt.options if option.value == candidate
56
+ )
57
+ if exact_values:
58
+ return exact_values[0]
59
+ if candidate.isdecimal():
60
+ number = int(candidate)
61
+ if 1 <= number <= len(prompt.options):
62
+ return prompt.options[number - 1].value
63
+ raise WizardAnswerError(f"numbered-text answer is out of range: {candidate}")
64
+ label_values = tuple(
65
+ option.value for option in prompt.options if option.label == candidate
66
+ )
67
+ if len(label_values) == 1:
68
+ return label_values[0]
69
+ if len(label_values) > 1:
70
+ raise WizardAnswerError(
71
+ f"numbered-text answer label is ambiguous: {candidate}"
72
+ )
73
+ raise WizardAnswerError(f"numbered-text answer is not an option: {candidate}")
74
+
75
+
76
+ def _normalize_numbered_answer(
77
+ prompt: WizardPrompt,
78
+ answer: str,
79
+ *,
80
+ multi: bool,
81
+ ) -> str:
82
+ if not multi:
83
+ return _normalize_numbered_item(prompt, answer)
84
+ if not (answer or "").strip():
85
+ return ""
86
+ return ",".join(
87
+ _normalize_numbered_item(prompt, item)
88
+ for item in answer.split(",")
89
+ )
90
+
91
+
92
+ def _group_answers(answer: str) -> dict[str, object]:
93
+ try:
94
+ answers = json.loads(answer or "{}")
95
+ except json.JSONDecodeError as exc:
96
+ raise WizardAnswerError(f"group answer must be a JSON object: {exc}") from exc
97
+ if not isinstance(answers, dict):
98
+ raise WizardAnswerError("group answer must be a JSON object")
99
+ return answers
100
+
101
+
102
+ def _normalize_group_answer(
103
+ prompt: WizardPrompt,
104
+ answer: str,
105
+ *,
106
+ numbered: bool,
107
+ ) -> str:
108
+ answers = _group_answers(answer)
109
+ if not numbered:
110
+ return json.dumps(answers, ensure_ascii=False, separators=(",", ":"))
111
+ questions = {question.step: question for question in prompt.questions}
112
+ normalized = dict(answers)
113
+ for step, raw_answer in answers.items():
114
+ question = questions.get(step)
115
+ if question is None:
116
+ continue
117
+ normalized[step] = _normalize_numbered_answer(
118
+ question,
119
+ str(raw_answer or ""),
120
+ multi=question.multi,
121
+ )
122
+ return json.dumps(normalized, ensure_ascii=False, separators=(",", ":"))
123
+
124
+
125
+ def normalize_planned_answer(
126
+ prompt: WizardPrompt,
127
+ protocol: AnswerProtocol,
128
+ answer: str,
129
+ ) -> str:
130
+ if protocol.kind == "exact-value":
131
+ return answer
132
+ if protocol.kind == "numbered":
133
+ return _normalize_numbered_answer(prompt, answer, multi=protocol.multi)
134
+ if protocol.kind in {"group-json", "numbered-group-json"}:
135
+ return _normalize_group_answer(
136
+ prompt,
137
+ answer,
138
+ numbered=protocol.kind == "numbered-group-json",
139
+ )
140
+ raise WizardAnswerError(f"unsupported answer protocol: {protocol.kind}")
@@ -0,0 +1 @@
1
+ """Internal command entrypoints used by Okstra host integrations."""
@@ -0,0 +1,334 @@
1
+ """JSON entrypoint for host catalog, resolution, and launch plans."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import os
7
+ import sys
8
+ from contextlib import contextmanager
9
+ from dataclasses import asdict
10
+ from pathlib import Path
11
+
12
+ from ..application.start_run import start_run
13
+ from ..application.resume_run import resume_run
14
+ from ..domain.host import (
15
+ HostAdapterContractError,
16
+ HostCapabilityMismatch,
17
+ HostNotRegistered,
18
+ HostResolutionContext,
19
+ HostSessionContext,
20
+ HostUnavailable,
21
+ LeadStartRequest,
22
+ LeadResumeRequest,
23
+ ProviderNotRegistered,
24
+ ProviderUnavailable,
25
+ )
26
+ from ..registry.host_registry import HostAdapterRegistry, default_host_registry
27
+
28
+
29
+ _ENTRYPOINT_ERRORS = (
30
+ HostAdapterContractError,
31
+ HostCapabilityMismatch,
32
+ HostNotRegistered,
33
+ HostUnavailable,
34
+ ProviderNotRegistered,
35
+ ProviderUnavailable,
36
+ ValueError,
37
+ )
38
+
39
+
40
+ def catalog_payload(registry: HostAdapterRegistry) -> dict[str, object]:
41
+ return {
42
+ "schemaVersion": 1,
43
+ "hosts": [asdict(descriptor) for descriptor in registry.catalog()],
44
+ }
45
+
46
+
47
+ def resolve_payload(
48
+ registry: HostAdapterRegistry,
49
+ requested_host: str,
50
+ context: HostResolutionContext,
51
+ ) -> dict[str, object]:
52
+ descriptor, host, reason = registry.resolve_request_details(
53
+ requested_host, context
54
+ )
55
+ return {
56
+ "ok": True,
57
+ "requestedRuntime": requested_host.strip().lower() or "auto",
58
+ "resolvedRuntime": descriptor.id,
59
+ "launchMode": descriptor.launch_mode,
60
+ "installTargets": sorted(descriptor.install_targets),
61
+ "host": host,
62
+ "confidence": "high",
63
+ "reason": reason,
64
+ "fallbackFrom": None,
65
+ "warnings": [],
66
+ }
67
+
68
+
69
+ def main(
70
+ argv: list[str] | None = None,
71
+ *,
72
+ registry: HostAdapterRegistry | None = None,
73
+ ) -> int:
74
+ parser = _build_parser()
75
+ args = parser.parse_args(argv)
76
+ try:
77
+ active_registry = registry or default_host_registry()
78
+ payload = _command_payload(args, active_registry)
79
+ except _ENTRYPOINT_ERRORS as exc:
80
+ sys.stderr.write(f"{exc}\n")
81
+ return 2
82
+ if getattr(args, "output_format", "json") == "null":
83
+ _write_null_launch_plan(payload)
84
+ else:
85
+ public_payload = {
86
+ key: value for key, value in payload.items()
87
+ if not key.startswith("_")
88
+ }
89
+ print(json.dumps(public_payload, default=_json_default, sort_keys=True))
90
+ return 0
91
+
92
+
93
+ def _build_parser() -> argparse.ArgumentParser:
94
+ parser = argparse.ArgumentParser(prog="okstra-hosts")
95
+ commands = parser.add_subparsers(dest="command", required=True)
96
+ catalog = commands.add_parser("catalog")
97
+ catalog.add_argument("--json", action="store_true")
98
+ resolve = commands.add_parser("resolve")
99
+ resolve.add_argument("--requested", required=True)
100
+ resolve.add_argument("--command", dest="runtime_command", required=True)
101
+ resolve.add_argument("--context-json", required=True)
102
+ launch = commands.add_parser("launch-plan")
103
+ _add_session_plan_args(launch)
104
+ launch.add_argument("--prepared-launch-json", default="")
105
+ resume = commands.add_parser("resume-plan")
106
+ _add_session_plan_args(resume)
107
+ resume.add_argument("--session-id", required=True)
108
+ resume.add_argument("--task-type", default="")
109
+ resume.add_argument("--request-arg", action="append", default=[])
110
+ probe = commands.add_parser("probe")
111
+ probe.add_argument("--host", required=True)
112
+ probe.add_argument(
113
+ "--entry-mode",
114
+ required=True,
115
+ choices=("spawn-process", "current-session"),
116
+ )
117
+ probe.add_argument("--project-root", required=True)
118
+ probe.add_argument("--home-dir", required=True)
119
+ probe.add_argument("--available-function", action="append", default=[])
120
+ return parser
121
+
122
+
123
+ def _add_session_plan_args(parser: argparse.ArgumentParser) -> None:
124
+ parser.add_argument("--host", required=True)
125
+ parser.add_argument(
126
+ "--entry-mode",
127
+ required=True,
128
+ choices=("spawn-process", "current-session"),
129
+ )
130
+ parser.add_argument(
131
+ "--format",
132
+ dest="output_format",
133
+ choices=("json", "null"),
134
+ default="json",
135
+ )
136
+ parser.add_argument("--project-root", default=str(Path.cwd()))
137
+
138
+
139
+ def _command_payload(
140
+ args: argparse.Namespace, registry: HostAdapterRegistry
141
+ ) -> dict[str, object]:
142
+ if args.command == "catalog":
143
+ return catalog_payload(registry)
144
+ if args.command == "resolve":
145
+ context = _parse_resolution_context(args.context_json)
146
+ try:
147
+ return resolve_payload(registry, args.requested, context)
148
+ except HostUnavailable as exc:
149
+ raise HostUnavailable(
150
+ _unclaimed_host_message(registry, args.runtime_command)
151
+ ) from exc
152
+ except HostNotRegistered as exc:
153
+ if args.requested.strip().lower() in {"", "auto"} and context.explicit_env_host:
154
+ allowed = "|".join(registry.ids())
155
+ raise HostNotRegistered(
156
+ f"invalid OKSTRA_RUNTIME_HOST {context.explicit_env_host!r} "
157
+ f"(expected: {allowed})."
158
+ ) from exc
159
+ raise
160
+ if args.command == "probe":
161
+ return _probe_payload(registry, args)
162
+ if args.command == "resume-plan":
163
+ return _resume_plan_payload(registry, args)
164
+ return _launch_plan_payload(registry, args)
165
+
166
+
167
+ def _probe_payload(
168
+ registry: HostAdapterRegistry,
169
+ args: argparse.Namespace,
170
+ ) -> dict[str, object]:
171
+ adapter = registry.resolve(args.host)
172
+ context = HostSessionContext(
173
+ host_id=adapter.descriptor.id,
174
+ entry_mode=args.entry_mode,
175
+ available_functions=frozenset(args.available_function),
176
+ interaction_surface="preflight",
177
+ )
178
+ with _probe_paths(args.project_root, args.home_dir):
179
+ readiness = adapter.probe(context)
180
+ statuses = {check.get("status") for check in readiness.checks}
181
+ status = "unknown" if "unavailable" in statuses else (
182
+ "ready" if readiness.ok else "blocked"
183
+ )
184
+ return {
185
+ "runtime": adapter.descriptor.id,
186
+ "status": status,
187
+ "checks": list(readiness.checks),
188
+ "relayContract": adapter.descriptor.relay_contract,
189
+ }
190
+
191
+
192
+ @contextmanager
193
+ def _probe_paths(project_root: str, home_dir: str):
194
+ keys = ("OKSTRA_PROBE_PROJECT_ROOT", "OKSTRA_PROBE_HOME_DIR")
195
+ previous = {key: os.environ.get(key) for key in keys}
196
+ os.environ[keys[0]] = project_root
197
+ os.environ[keys[1]] = home_dir
198
+ try:
199
+ yield
200
+ finally:
201
+ for key, value in previous.items():
202
+ if value is None:
203
+ os.environ.pop(key, None)
204
+ else:
205
+ os.environ[key] = value
206
+
207
+
208
+ def _parse_resolution_context(raw_context: str) -> HostResolutionContext:
209
+ try:
210
+ raw = json.loads(raw_context)
211
+ except json.JSONDecodeError as exc:
212
+ raise ValueError("invalid host resolution context JSON") from exc
213
+ if not isinstance(raw, dict):
214
+ raise ValueError("invalid host resolution context JSON")
215
+ env = raw.get("env", {})
216
+ capabilities = raw.get("capabilities", {})
217
+ if not isinstance(env, dict) or not isinstance(capabilities, dict):
218
+ raise ValueError("invalid host resolution context JSON")
219
+ explicit_env_host = env.get("OKSTRA_RUNTIME_HOST", "")
220
+ if not isinstance(explicit_env_host, str):
221
+ raise ValueError("invalid host resolution context JSON")
222
+ signals = frozenset(
223
+ signal
224
+ for capability, signal in (
225
+ ("claudeSkillHandoff", "claude-skill-handoff"),
226
+ ("tmuxAvailable", "tmux"),
227
+ )
228
+ if capabilities.get(capability) is True
229
+ )
230
+ return HostResolutionContext(explicit_env_host, signals)
231
+
232
+
233
+ def _unclaimed_host_message(registry: HostAdapterRegistry, command: str) -> str:
234
+ runtime_flag = "--runtime" if command in {
235
+ "install", "ensure-installed", "doctor"
236
+ } else "--lead-runtime"
237
+ allowed = "|".join(registry.ids())
238
+ return (
239
+ f"host unknown: set OKSTRA_RUNTIME_HOST={allowed} or pass "
240
+ f"{runtime_flag} explicitly."
241
+ )
242
+
243
+
244
+ def _launch_plan_payload(
245
+ registry: HostAdapterRegistry,
246
+ args: argparse.Namespace,
247
+ ) -> dict[str, object]:
248
+ adapter = registry.resolve(args.host)
249
+ context = HostSessionContext(
250
+ host_id=adapter.descriptor.id,
251
+ entry_mode=args.entry_mode,
252
+ available_functions=frozenset(),
253
+ interaction_surface="terminal",
254
+ )
255
+ project_root, request_argv = _launch_request(args, adapter.descriptor.id)
256
+ request = LeadStartRequest(project_root, "", request_argv)
257
+ plan = start_run(request, context, adapter)
258
+ return {"ok": True, "_project_root": str(project_root), **asdict(plan)}
259
+
260
+
261
+ def _resume_plan_payload(
262
+ registry: HostAdapterRegistry,
263
+ args: argparse.Namespace,
264
+ ) -> dict[str, object]:
265
+ adapter = registry.resolve(args.host)
266
+ context = HostSessionContext(
267
+ host_id=adapter.descriptor.id,
268
+ entry_mode=args.entry_mode,
269
+ available_functions=frozenset(),
270
+ interaction_surface="terminal",
271
+ )
272
+ project_root = Path(args.project_root)
273
+ request = LeadResumeRequest(
274
+ project_root,
275
+ args.task_type,
276
+ args.session_id,
277
+ tuple(args.request_arg),
278
+ )
279
+ plan = resume_run(request, context, adapter)
280
+ return {"ok": True, "_project_root": str(project_root), **asdict(plan)}
281
+
282
+
283
+ def _launch_request(
284
+ args: argparse.Namespace,
285
+ canonical_host_id: str,
286
+ ) -> tuple[Path, tuple[str, ...]]:
287
+ if not args.prepared_launch_json:
288
+ return Path(args.project_root), ()
289
+ try:
290
+ prepared = json.loads(args.prepared_launch_json)
291
+ except json.JSONDecodeError as exc:
292
+ raise ValueError("invalid prepared launch JSON") from exc
293
+ if not isinstance(prepared, dict):
294
+ raise ValueError("prepared launch JSON must be an object")
295
+ if prepared.get("leadRuntime") != canonical_host_id:
296
+ raise ValueError("prepared launch runtime does not match selected host")
297
+ project_root = prepared.get("projectRoot")
298
+ request_argv = prepared.get("launchRequestArgv")
299
+ if not isinstance(project_root, str) or not project_root:
300
+ raise ValueError("prepared launch is missing projectRoot")
301
+ if not isinstance(request_argv, list) or not all(
302
+ isinstance(arg, str) for arg in request_argv
303
+ ):
304
+ raise ValueError("prepared launch is missing launchRequestArgv")
305
+ return Path(project_root), tuple(request_argv)
306
+
307
+
308
+ def _write_null_launch_plan(payload: dict[str, object]) -> None:
309
+ fields = [
310
+ payload["_project_root"],
311
+ payload["sandbox_note"],
312
+ str(len(payload["env"])),
313
+ ]
314
+ fields.extend(
315
+ f"{key}={value}" for key, value in payload["env"].items()
316
+ )
317
+ fields.extend(payload["argv"])
318
+ for field in fields:
319
+ value = str(field)
320
+ if "\0" in value:
321
+ raise ValueError("launch plan field contains a null byte")
322
+ sys.stdout.write(value + "\0")
323
+
324
+
325
+ def _json_default(value: object) -> object:
326
+ if isinstance(value, (frozenset, set)):
327
+ return sorted(value)
328
+ if isinstance(value, Path):
329
+ return str(value)
330
+ raise TypeError(f"not JSON serializable: {type(value).__name__}")
331
+
332
+
333
+ if __name__ == "__main__":
334
+ raise SystemExit(main())