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.
- package/README.md +7 -5
- package/docs/architecture.md +12 -7
- package/docs/cli.md +8 -5
- package/docs/project-structure-overview.md +7 -5
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/claude-worker.md +1 -0
- package/runtime/bin/lib/okstra/cli.sh +5 -0
- package/runtime/bin/lib/okstra/globals.sh +2 -0
- package/runtime/bin/lib/okstra/usage.sh +5 -5
- package/runtime/bin/okstra.sh +87 -91
- package/runtime/prompts/lead/adapters/cmux.md +1 -1
- package/runtime/prompts/lead/okstra-lead-contract.md +1 -0
- package/runtime/prompts/profiles/implementation-planning.md +1 -1
- package/runtime/python/okstra_ctl/adapters/accounting/__init__.py +11 -0
- package/runtime/python/okstra_ctl/adapters/accounting/claude_jsonl.py +17 -0
- package/runtime/python/okstra_ctl/adapters/accounting/cli_artifact.py +17 -0
- package/runtime/python/okstra_ctl/adapters/accounting/unavailable.py +19 -0
- package/runtime/python/okstra_ctl/adapters/dispatch/__init__.py +92 -0
- package/runtime/python/okstra_ctl/adapters/dispatch/cli_wrapper.py +54 -0
- package/runtime/python/okstra_ctl/adapters/dispatch/cmux.py +68 -0
- package/runtime/python/okstra_ctl/adapters/dispatch/native_team.py +13 -0
- package/runtime/python/okstra_ctl/adapters/hosts/antigravity/adapter.py +60 -0
- package/runtime/python/okstra_ctl/adapters/hosts/antigravity/manifest.json +1 -0
- package/runtime/{prompts/lead/adapters/antigravity.md → python/okstra_ctl/adapters/hosts/antigravity/relay.md} +52 -0
- package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +292 -0
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +120 -0
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/manifest.json +1 -0
- package/runtime/{prompts/lead/adapters/claude-code.md → python/okstra_ctl/adapters/hosts/claude-code/relay.md} +111 -0
- package/runtime/python/okstra_ctl/adapters/hosts/codex/adapter.py +60 -0
- package/runtime/python/okstra_ctl/adapters/hosts/codex/manifest.json +1 -0
- package/runtime/{prompts/lead/adapters/codex.md → python/okstra_ctl/adapters/hosts/codex/relay.md} +52 -0
- package/runtime/python/okstra_ctl/adapters/hosts/external/adapter.py +72 -0
- package/runtime/python/okstra_ctl/adapters/hosts/external/manifest.json +1 -0
- package/runtime/{prompts/lead/adapters/external.md → python/okstra_ctl/adapters/hosts/external/relay.md} +52 -0
- package/runtime/python/okstra_ctl/adapters/hosts/grok/adapter.py +63 -0
- package/runtime/python/okstra_ctl/adapters/hosts/grok/manifest.json +1 -0
- package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +90 -0
- package/runtime/python/okstra_ctl/adapters/hosts/kimi/adapter.py +63 -0
- package/runtime/python/okstra_ctl/adapters/hosts/kimi/manifest.json +1 -0
- package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +90 -0
- package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +35 -0
- package/runtime/python/okstra_ctl/adapters/providers/antigravity/manifest.json +1 -0
- package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +55 -0
- package/runtime/python/okstra_ctl/adapters/providers/claude/manifest.json +1 -0
- package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +43 -0
- package/runtime/python/okstra_ctl/adapters/providers/codex/manifest.json +1 -0
- package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +32 -0
- package/runtime/python/okstra_ctl/adapters/providers/grok/manifest.json +1 -0
- package/runtime/python/okstra_ctl/adapters/providers/kimi/adapter.py +38 -0
- package/runtime/python/okstra_ctl/adapters/providers/kimi/manifest.json +1 -0
- package/runtime/python/okstra_ctl/application/__init__.py +1 -0
- package/runtime/python/okstra_ctl/application/advance_wizard.py +25 -0
- package/runtime/python/okstra_ctl/application/collect_usage.py +15 -0
- package/runtime/python/okstra_ctl/application/dispatch_assignments.py +15 -0
- package/runtime/python/okstra_ctl/application/resolve_assignment.py +93 -0
- package/runtime/python/okstra_ctl/application/resume_run.py +21 -0
- package/runtime/python/okstra_ctl/application/start_run.py +21 -0
- package/runtime/python/okstra_ctl/codex_dispatch.py +49 -826
- package/runtime/python/okstra_ctl/dispatch_core.py +240 -29
- package/runtime/python/okstra_ctl/dispatch_state.py +17 -0
- package/runtime/python/okstra_ctl/domain/__init__.py +34 -0
- package/runtime/python/okstra_ctl/domain/host.py +100 -0
- package/runtime/python/okstra_ctl/domain/provider.py +66 -0
- package/runtime/python/okstra_ctl/domain/wizard/__init__.py +19 -0
- package/runtime/python/okstra_ctl/domain/wizard/interaction.py +140 -0
- package/runtime/python/okstra_ctl/entrypoints/__init__.py +1 -0
- package/runtime/python/okstra_ctl/entrypoints/hosts.py +334 -0
- package/runtime/python/okstra_ctl/models.py +54 -269
- package/runtime/python/okstra_ctl/ports/__init__.py +15 -0
- package/runtime/python/okstra_ctl/ports/host.py +32 -0
- package/runtime/python/okstra_ctl/ports/interaction.py +15 -0
- package/runtime/python/okstra_ctl/ports/lead_session.py +25 -0
- package/runtime/python/okstra_ctl/ports/usage_accounting.py +23 -0
- package/runtime/python/okstra_ctl/ports/worker_dispatch.py +32 -0
- package/runtime/python/okstra_ctl/registry/__init__.py +13 -0
- package/runtime/python/okstra_ctl/registry/factory_loader.py +32 -0
- package/runtime/python/okstra_ctl/registry/host_discovery.py +124 -0
- package/runtime/python/okstra_ctl/registry/host_registry.py +365 -0
- package/runtime/python/okstra_ctl/registry/provider_registry.py +149 -0
- package/runtime/python/okstra_ctl/render.py +145 -47
- package/runtime/python/okstra_ctl/run.py +80 -58
- package/runtime/python/okstra_ctl/session.py +1 -1
- package/runtime/python/okstra_ctl/team.py +44 -32
- package/runtime/python/okstra_ctl/wizard.py +162 -64
- package/runtime/python/okstra_ctl/worker_audit_ledger.py +29 -4
- package/runtime/python/okstra_token_usage/collect.py +34 -6
- package/runtime/schemas/final-report-v1.0.schema.json +3989 -1085
- package/runtime/schemas/final-report-v2.0.schema.json +5622 -1451
- package/runtime/skills/okstra-run/SKILL.md +73 -28
- package/runtime/templates/implementation-worker-preamble.md +1 -1
- package/runtime/templates/reports/final-report.template.md +3 -3
- package/runtime/templates/reports/html/i18n/en.json +3 -1
- package/runtime/templates/reports/html/i18n/ko.json +3 -1
- package/runtime/templates/reports/html/tasks/implementation-planning.template.html +1 -1
- package/runtime/templates/worker-prompt-preamble.md +1 -1
- package/runtime/validators/validate-implementation-plan-stages.py +17 -22
- package/runtime/validators/validate-report-views.py +0 -39
- package/runtime/validators/validate-run.py +8 -7
- package/runtime/validators/validate_session_conformance.py +7 -2
- package/src/commands/execute/render-bundle.mjs +4 -4
- package/src/commands/execute/run.mjs +8 -25
- package/src/commands/execute/wizard.mjs +33 -13
- package/src/commands/lifecycle/doctor.mjs +10 -10
- package/src/commands/lifecycle/install.mjs +53 -30
- package/src/commands/lifecycle/preflight.mjs +14 -4
- package/src/lib/host-registry-client.mjs +176 -0
- package/src/lib/runtime-manifest.mjs +6 -8
- package/runtime/python/okstra_ctl/lead_runtime.py +0 -115
- package/runtime/python/okstra_ctl/runner_resolution.py +0 -103
- package/src/lib/runtime-readiness.mjs +0 -90
- 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())
|