codex-autorunner 0.1.1__py3-none-any.whl → 1.0.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.
- codex_autorunner/__main__.py +4 -0
- codex_autorunner/agents/__init__.py +20 -0
- codex_autorunner/agents/base.py +2 -2
- codex_autorunner/agents/codex/harness.py +1 -1
- codex_autorunner/agents/opencode/__init__.py +4 -0
- codex_autorunner/agents/opencode/agent_config.py +104 -0
- codex_autorunner/agents/opencode/client.py +305 -28
- codex_autorunner/agents/opencode/harness.py +71 -20
- codex_autorunner/agents/opencode/logging.py +225 -0
- codex_autorunner/agents/opencode/run_prompt.py +261 -0
- codex_autorunner/agents/opencode/runtime.py +1202 -132
- codex_autorunner/agents/opencode/supervisor.py +194 -68
- codex_autorunner/agents/registry.py +258 -0
- codex_autorunner/agents/types.py +2 -2
- codex_autorunner/api.py +25 -0
- codex_autorunner/bootstrap.py +19 -40
- codex_autorunner/cli.py +234 -151
- codex_autorunner/core/about_car.py +44 -32
- codex_autorunner/core/adapter_utils.py +21 -0
- codex_autorunner/core/app_server_events.py +15 -6
- codex_autorunner/core/app_server_logging.py +55 -15
- codex_autorunner/core/app_server_prompts.py +28 -259
- codex_autorunner/core/app_server_threads.py +15 -26
- codex_autorunner/core/circuit_breaker.py +183 -0
- codex_autorunner/core/codex_runner.py +6 -0
- codex_autorunner/core/config.py +555 -133
- codex_autorunner/core/docs.py +54 -9
- codex_autorunner/core/drafts.py +82 -0
- codex_autorunner/core/engine.py +828 -274
- codex_autorunner/core/exceptions.py +60 -0
- codex_autorunner/core/flows/__init__.py +25 -0
- codex_autorunner/core/flows/controller.py +178 -0
- codex_autorunner/core/flows/definition.py +82 -0
- codex_autorunner/core/flows/models.py +75 -0
- codex_autorunner/core/flows/runtime.py +351 -0
- codex_autorunner/core/flows/store.py +485 -0
- codex_autorunner/core/flows/transition.py +133 -0
- codex_autorunner/core/flows/worker_process.py +242 -0
- codex_autorunner/core/hub.py +21 -13
- codex_autorunner/core/locks.py +118 -1
- codex_autorunner/core/logging_utils.py +9 -6
- codex_autorunner/core/path_utils.py +123 -0
- codex_autorunner/core/prompt.py +15 -7
- codex_autorunner/core/redaction.py +29 -0
- codex_autorunner/core/retry.py +61 -0
- codex_autorunner/core/review.py +888 -0
- codex_autorunner/core/review_context.py +161 -0
- codex_autorunner/core/run_index.py +223 -0
- codex_autorunner/core/runner_controller.py +44 -1
- codex_autorunner/core/runner_process.py +30 -1
- codex_autorunner/core/sqlite_utils.py +32 -0
- codex_autorunner/core/state.py +273 -44
- codex_autorunner/core/static_assets.py +55 -0
- codex_autorunner/core/supervisor_utils.py +67 -0
- codex_autorunner/core/text_delta_coalescer.py +43 -0
- codex_autorunner/core/update.py +20 -11
- codex_autorunner/core/update_runner.py +2 -0
- codex_autorunner/core/usage.py +107 -75
- codex_autorunner/core/utils.py +167 -3
- codex_autorunner/discovery.py +3 -3
- codex_autorunner/flows/ticket_flow/__init__.py +3 -0
- codex_autorunner/flows/ticket_flow/definition.py +91 -0
- codex_autorunner/integrations/agents/__init__.py +27 -0
- codex_autorunner/integrations/agents/agent_backend.py +142 -0
- codex_autorunner/integrations/agents/codex_backend.py +307 -0
- codex_autorunner/integrations/agents/opencode_backend.py +325 -0
- codex_autorunner/integrations/agents/run_event.py +71 -0
- codex_autorunner/integrations/app_server/client.py +708 -153
- codex_autorunner/integrations/app_server/supervisor.py +59 -33
- codex_autorunner/integrations/telegram/adapter.py +474 -185
- codex_autorunner/integrations/telegram/api_schemas.py +120 -0
- codex_autorunner/integrations/telegram/config.py +239 -1
- codex_autorunner/integrations/telegram/constants.py +19 -1
- codex_autorunner/integrations/telegram/dispatch.py +44 -8
- codex_autorunner/integrations/telegram/doctor.py +47 -0
- codex_autorunner/integrations/telegram/handlers/approvals.py +12 -10
- codex_autorunner/integrations/telegram/handlers/callbacks.py +15 -1
- codex_autorunner/integrations/telegram/handlers/commands/__init__.py +29 -0
- codex_autorunner/integrations/telegram/handlers/commands/approvals.py +173 -0
- codex_autorunner/integrations/telegram/handlers/commands/execution.py +2595 -0
- codex_autorunner/integrations/telegram/handlers/commands/files.py +1408 -0
- codex_autorunner/integrations/telegram/handlers/commands/flows.py +227 -0
- codex_autorunner/integrations/telegram/handlers/commands/formatting.py +81 -0
- codex_autorunner/integrations/telegram/handlers/commands/github.py +1688 -0
- codex_autorunner/integrations/telegram/handlers/commands/shared.py +190 -0
- codex_autorunner/integrations/telegram/handlers/commands/voice.py +112 -0
- codex_autorunner/integrations/telegram/handlers/commands/workspace.py +2043 -0
- codex_autorunner/integrations/telegram/handlers/commands_runtime.py +954 -5689
- codex_autorunner/integrations/telegram/handlers/{commands.py → commands_spec.py} +11 -4
- codex_autorunner/integrations/telegram/handlers/messages.py +374 -49
- codex_autorunner/integrations/telegram/handlers/questions.py +389 -0
- codex_autorunner/integrations/telegram/handlers/selections.py +6 -4
- codex_autorunner/integrations/telegram/handlers/utils.py +171 -0
- codex_autorunner/integrations/telegram/helpers.py +90 -18
- codex_autorunner/integrations/telegram/notifications.py +126 -35
- codex_autorunner/integrations/telegram/outbox.py +214 -43
- codex_autorunner/integrations/telegram/progress_stream.py +42 -19
- codex_autorunner/integrations/telegram/runtime.py +24 -13
- codex_autorunner/integrations/telegram/service.py +500 -129
- codex_autorunner/integrations/telegram/state.py +1278 -330
- codex_autorunner/integrations/telegram/ticket_flow_bridge.py +322 -0
- codex_autorunner/integrations/telegram/transport.py +37 -4
- codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
- codex_autorunner/integrations/telegram/types.py +22 -2
- codex_autorunner/integrations/telegram/voice.py +14 -15
- codex_autorunner/manifest.py +2 -0
- codex_autorunner/plugin_api.py +22 -0
- codex_autorunner/routes/__init__.py +25 -14
- codex_autorunner/routes/agents.py +18 -78
- codex_autorunner/routes/analytics.py +239 -0
- codex_autorunner/routes/base.py +142 -113
- codex_autorunner/routes/file_chat.py +836 -0
- codex_autorunner/routes/flows.py +980 -0
- codex_autorunner/routes/messages.py +459 -0
- codex_autorunner/routes/repos.py +17 -0
- codex_autorunner/routes/review.py +148 -0
- codex_autorunner/routes/sessions.py +16 -8
- codex_autorunner/routes/settings.py +22 -0
- codex_autorunner/routes/shared.py +33 -3
- codex_autorunner/routes/system.py +22 -1
- codex_autorunner/routes/usage.py +87 -0
- codex_autorunner/routes/voice.py +5 -13
- codex_autorunner/routes/workspace.py +271 -0
- codex_autorunner/server.py +2 -1
- codex_autorunner/static/agentControls.js +9 -1
- codex_autorunner/static/agentEvents.js +248 -0
- codex_autorunner/static/app.js +27 -22
- codex_autorunner/static/autoRefresh.js +29 -1
- codex_autorunner/static/bootstrap.js +1 -0
- codex_autorunner/static/bus.js +1 -0
- codex_autorunner/static/cache.js +1 -0
- codex_autorunner/static/constants.js +20 -4
- codex_autorunner/static/dashboard.js +162 -150
- codex_autorunner/static/diffRenderer.js +37 -0
- codex_autorunner/static/docChatCore.js +324 -0
- codex_autorunner/static/docChatStorage.js +65 -0
- codex_autorunner/static/docChatVoice.js +65 -0
- codex_autorunner/static/docEditor.js +133 -0
- codex_autorunner/static/env.js +1 -0
- codex_autorunner/static/eventSummarizer.js +166 -0
- codex_autorunner/static/fileChat.js +182 -0
- codex_autorunner/static/health.js +155 -0
- codex_autorunner/static/hub.js +67 -126
- codex_autorunner/static/index.html +788 -807
- codex_autorunner/static/liveUpdates.js +59 -0
- codex_autorunner/static/loader.js +1 -0
- codex_autorunner/static/messages.js +470 -0
- codex_autorunner/static/mobileCompact.js +2 -1
- codex_autorunner/static/settings.js +24 -205
- codex_autorunner/static/styles.css +7577 -3758
- codex_autorunner/static/tabs.js +28 -5
- codex_autorunner/static/terminal.js +14 -0
- codex_autorunner/static/terminalManager.js +53 -59
- codex_autorunner/static/ticketChatActions.js +333 -0
- codex_autorunner/static/ticketChatEvents.js +16 -0
- codex_autorunner/static/ticketChatStorage.js +16 -0
- codex_autorunner/static/ticketChatStream.js +264 -0
- codex_autorunner/static/ticketEditor.js +750 -0
- codex_autorunner/static/ticketVoice.js +9 -0
- codex_autorunner/static/tickets.js +1315 -0
- codex_autorunner/static/utils.js +32 -3
- codex_autorunner/static/voice.js +21 -7
- codex_autorunner/static/workspace.js +672 -0
- codex_autorunner/static/workspaceApi.js +53 -0
- codex_autorunner/static/workspaceFileBrowser.js +504 -0
- codex_autorunner/tickets/__init__.py +20 -0
- codex_autorunner/tickets/agent_pool.py +377 -0
- codex_autorunner/tickets/files.py +85 -0
- codex_autorunner/tickets/frontmatter.py +55 -0
- codex_autorunner/tickets/lint.py +102 -0
- codex_autorunner/tickets/models.py +95 -0
- codex_autorunner/tickets/outbox.py +232 -0
- codex_autorunner/tickets/replies.py +179 -0
- codex_autorunner/tickets/runner.py +823 -0
- codex_autorunner/tickets/spec_ingest.py +77 -0
- codex_autorunner/voice/capture.py +7 -7
- codex_autorunner/voice/service.py +51 -9
- codex_autorunner/web/app.py +419 -199
- codex_autorunner/web/hub_jobs.py +13 -2
- codex_autorunner/web/middleware.py +47 -13
- codex_autorunner/web/pty_session.py +26 -13
- codex_autorunner/web/schemas.py +114 -109
- codex_autorunner/web/static_assets.py +55 -42
- codex_autorunner/web/static_refresh.py +86 -0
- codex_autorunner/workspace/__init__.py +40 -0
- codex_autorunner/workspace/paths.py +319 -0
- {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/METADATA +20 -21
- codex_autorunner-1.0.0.dist-info/RECORD +251 -0
- {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/WHEEL +1 -1
- codex_autorunner/core/doc_chat.py +0 -1415
- codex_autorunner/core/snapshot.py +0 -580
- codex_autorunner/integrations/github/chatops.py +0 -268
- codex_autorunner/integrations/github/pr_flow.py +0 -1314
- codex_autorunner/routes/docs.py +0 -381
- codex_autorunner/routes/github.py +0 -327
- codex_autorunner/routes/runs.py +0 -118
- codex_autorunner/spec_ingest.py +0 -788
- codex_autorunner/static/docChatActions.js +0 -279
- codex_autorunner/static/docChatEvents.js +0 -300
- codex_autorunner/static/docChatRender.js +0 -205
- codex_autorunner/static/docChatStream.js +0 -361
- codex_autorunner/static/docs.js +0 -20
- codex_autorunner/static/docsClipboard.js +0 -69
- codex_autorunner/static/docsCrud.js +0 -257
- codex_autorunner/static/docsDocUpdates.js +0 -62
- codex_autorunner/static/docsDrafts.js +0 -16
- codex_autorunner/static/docsElements.js +0 -69
- codex_autorunner/static/docsInit.js +0 -274
- codex_autorunner/static/docsParse.js +0 -160
- codex_autorunner/static/docsSnapshot.js +0 -87
- codex_autorunner/static/docsSpecIngest.js +0 -263
- codex_autorunner/static/docsState.js +0 -127
- codex_autorunner/static/docsThreadRegistry.js +0 -44
- codex_autorunner/static/docsUi.js +0 -153
- codex_autorunner/static/docsVoice.js +0 -56
- codex_autorunner/static/github.js +0 -442
- codex_autorunner/static/logs.js +0 -640
- codex_autorunner/static/runs.js +0 -409
- codex_autorunner/static/snapshot.js +0 -124
- codex_autorunner/static/state.js +0 -86
- codex_autorunner/static/todoPreview.js +0 -27
- codex_autorunner/workspace.py +0 -16
- codex_autorunner-0.1.1.dist-info/RECORD +0 -191
- {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/entry_points.txt +0 -0
- {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/licenses/LICENSE +0 -0
- {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/top_level.txt +0 -0
|
@@ -1 +1,21 @@
|
|
|
1
1
|
"""Agent harness abstractions."""
|
|
2
|
+
|
|
3
|
+
from .registry import (
|
|
4
|
+
AgentCapability,
|
|
5
|
+
AgentDescriptor,
|
|
6
|
+
get_agent_descriptor,
|
|
7
|
+
get_available_agents,
|
|
8
|
+
get_registered_agents,
|
|
9
|
+
has_capability,
|
|
10
|
+
validate_agent_id,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"AgentCapability",
|
|
15
|
+
"AgentDescriptor",
|
|
16
|
+
"get_registered_agents",
|
|
17
|
+
"get_available_agents",
|
|
18
|
+
"get_agent_descriptor",
|
|
19
|
+
"validate_agent_id",
|
|
20
|
+
"has_capability",
|
|
21
|
+
]
|
codex_autorunner/agents/base.py
CHANGED
|
@@ -3,11 +3,11 @@ from __future__ import annotations
|
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
from typing import Any, AsyncIterator, Optional, Protocol
|
|
5
5
|
|
|
6
|
-
from .types import ConversationRef, ModelCatalog, TurnRef
|
|
6
|
+
from .types import AgentId, ConversationRef, ModelCatalog, TurnRef
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
class AgentHarness(Protocol):
|
|
10
|
-
agent_id:
|
|
10
|
+
agent_id: AgentId
|
|
11
11
|
display_name: str
|
|
12
12
|
|
|
13
13
|
async def ensure_ready(self, workspace_root: Path) -> None: ...
|
|
@@ -3,12 +3,16 @@
|
|
|
3
3
|
from .client import OpenCodeClient
|
|
4
4
|
from .events import SSEEvent, parse_sse_lines
|
|
5
5
|
from .harness import OpenCodeHarness
|
|
6
|
+
from .run_prompt import OpenCodeRunConfig, OpenCodeRunResult, run_opencode_prompt
|
|
6
7
|
from .supervisor import OpenCodeSupervisor
|
|
7
8
|
|
|
8
9
|
__all__ = [
|
|
9
10
|
"OpenCodeClient",
|
|
10
11
|
"OpenCodeHarness",
|
|
12
|
+
"OpenCodeRunConfig",
|
|
13
|
+
"OpenCodeRunResult",
|
|
11
14
|
"OpenCodeSupervisor",
|
|
12
15
|
"SSEEvent",
|
|
13
16
|
"parse_sse_lines",
|
|
17
|
+
"run_opencode_prompt",
|
|
14
18
|
]
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def ensure_agent_config(
|
|
12
|
+
workspace_root: Path,
|
|
13
|
+
agent_id: str,
|
|
14
|
+
model: Optional[str],
|
|
15
|
+
title: Optional[str] = None,
|
|
16
|
+
description: Optional[str] = None,
|
|
17
|
+
) -> None:
|
|
18
|
+
"""Ensure .opencode/agent/<agent_id>.md exists with frontmatter config.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
workspace_root: Path to the workspace root
|
|
22
|
+
agent_id: Agent ID (e.g., "subagent")
|
|
23
|
+
model: Model ID in format "providerID/modelID" (e.g., "zai-coding-plan/glm-4.7-flashx")
|
|
24
|
+
title: Optional title for the agent
|
|
25
|
+
description: Optional description for the agent
|
|
26
|
+
"""
|
|
27
|
+
if model is None:
|
|
28
|
+
logger.debug(f"Skipping agent config for {agent_id}: no model configured")
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
agent_dir = workspace_root / ".opencode" / "agent"
|
|
32
|
+
agent_file = agent_dir / f"{agent_id}.md"
|
|
33
|
+
|
|
34
|
+
# Check if file already exists and has the correct model
|
|
35
|
+
if agent_file.exists():
|
|
36
|
+
existing_content = agent_file.read_text(encoding="utf-8")
|
|
37
|
+
existing_model = _extract_model_from_frontmatter(existing_content)
|
|
38
|
+
if existing_model == model:
|
|
39
|
+
logger.debug(f"Agent config already exists for {agent_id}: {agent_file}")
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
# Create agent directory if needed
|
|
43
|
+
await asyncio.to_thread(agent_dir.mkdir, parents=True, exist_ok=True)
|
|
44
|
+
|
|
45
|
+
# Build agent markdown with frontmatter
|
|
46
|
+
content = _build_agent_md(
|
|
47
|
+
agent_id=agent_id,
|
|
48
|
+
model=model,
|
|
49
|
+
title=title or agent_id,
|
|
50
|
+
description=description or f"Subagent for {agent_id} tasks",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Write atomically
|
|
54
|
+
await asyncio.to_thread(agent_file.write_text, content, encoding="utf-8")
|
|
55
|
+
logger.info(f"Created agent config: {agent_file} with model {model}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _build_agent_md(
|
|
59
|
+
agent_id: str,
|
|
60
|
+
model: str,
|
|
61
|
+
title: str,
|
|
62
|
+
description: str,
|
|
63
|
+
) -> str:
|
|
64
|
+
"""Generate markdown with YAML frontmatter.
|
|
65
|
+
|
|
66
|
+
Frontmatter format per OpenCode config schema:
|
|
67
|
+
---
|
|
68
|
+
agent: <agent_id>
|
|
69
|
+
title: "<title>"
|
|
70
|
+
description: "<description>"
|
|
71
|
+
model: <providerID>/<modelID>
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
<Optional agent instructions go here>
|
|
75
|
+
"""
|
|
76
|
+
return f"""---
|
|
77
|
+
agent: {agent_id}
|
|
78
|
+
title: "{title}"
|
|
79
|
+
description: "{description}"
|
|
80
|
+
model: {model}
|
|
81
|
+
---
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _extract_model_from_frontmatter(content: str) -> Optional[str]:
|
|
86
|
+
"""Extract model value from YAML frontmatter.
|
|
87
|
+
|
|
88
|
+
Returns None if frontmatter or model field is not found.
|
|
89
|
+
"""
|
|
90
|
+
lines = content.splitlines()
|
|
91
|
+
if not lines or not lines[0].startswith("---"):
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
for _i, line in enumerate(lines[1:], start=1):
|
|
95
|
+
if line.startswith("---"):
|
|
96
|
+
break
|
|
97
|
+
if line.startswith("model:"):
|
|
98
|
+
model = line.split(":", 1)[1].strip()
|
|
99
|
+
return model if model else None
|
|
100
|
+
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
__all__ = ["ensure_agent_config"]
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
4
|
+
import dataclasses
|
|
3
5
|
import json
|
|
4
6
|
import logging
|
|
5
|
-
|
|
7
|
+
import re
|
|
8
|
+
from typing import Any, AsyncIterator, Iterable, Optional
|
|
6
9
|
|
|
7
10
|
import httpx
|
|
8
11
|
|
|
@@ -12,6 +15,15 @@ from .events import SSEEvent, parse_sse_lines
|
|
|
12
15
|
_MAX_INVALID_JSON_PREVIEW_BYTES = 512
|
|
13
16
|
|
|
14
17
|
|
|
18
|
+
@dataclasses.dataclass
|
|
19
|
+
class OpenCodeApiProfile:
|
|
20
|
+
"""Detected OpenCode API capabilities from OpenAPI spec."""
|
|
21
|
+
|
|
22
|
+
supports_prompt_async: bool = True
|
|
23
|
+
supports_global_endpoints: bool = True
|
|
24
|
+
spec_fetched: bool = False
|
|
25
|
+
|
|
26
|
+
|
|
15
27
|
class OpenCodeProtocolError(Exception):
|
|
16
28
|
def __init__(
|
|
17
29
|
self,
|
|
@@ -27,6 +39,43 @@ class OpenCodeProtocolError(Exception):
|
|
|
27
39
|
self.body_preview = body_preview
|
|
28
40
|
|
|
29
41
|
|
|
42
|
+
def _normalize_sse_event(event: SSEEvent) -> SSEEvent:
|
|
43
|
+
event_type = event.event
|
|
44
|
+
raw_data = event.data or ""
|
|
45
|
+
payload_obj: Optional[dict[str, Any]] = None
|
|
46
|
+
try:
|
|
47
|
+
payload_obj = json.loads(raw_data) if raw_data else None
|
|
48
|
+
except (json.JSONDecodeError, TypeError):
|
|
49
|
+
payload_obj = None
|
|
50
|
+
|
|
51
|
+
if isinstance(payload_obj, dict) and isinstance(payload_obj.get("payload"), dict):
|
|
52
|
+
outer = payload_obj
|
|
53
|
+
inner = dict(outer.get("payload") or {})
|
|
54
|
+
if "type" not in inner and isinstance(outer.get("type"), str):
|
|
55
|
+
inner["type"] = outer["type"]
|
|
56
|
+
for key in ("sessionID", "sessionId", "session_id"):
|
|
57
|
+
if key in outer and key not in inner:
|
|
58
|
+
inner[key] = outer[key]
|
|
59
|
+
if "session" in outer and "session" not in inner:
|
|
60
|
+
inner["session"] = outer["session"]
|
|
61
|
+
if "properties" in outer and "properties" not in inner:
|
|
62
|
+
inner["properties"] = outer["properties"]
|
|
63
|
+
payload_obj = inner
|
|
64
|
+
|
|
65
|
+
if isinstance(payload_obj, dict):
|
|
66
|
+
payload_type = payload_obj.get("type")
|
|
67
|
+
if isinstance(payload_type, str) and payload_type:
|
|
68
|
+
event_type = payload_type
|
|
69
|
+
raw_data = json.dumps(payload_obj)
|
|
70
|
+
|
|
71
|
+
return SSEEvent(
|
|
72
|
+
event=event_type,
|
|
73
|
+
data=raw_data,
|
|
74
|
+
id=event.id,
|
|
75
|
+
retry=event.retry,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
30
79
|
class OpenCodeClient:
|
|
31
80
|
def __init__(
|
|
32
81
|
self,
|
|
@@ -42,10 +91,62 @@ class OpenCodeClient:
|
|
|
42
91
|
timeout=timeout,
|
|
43
92
|
)
|
|
44
93
|
self._logger = logger or logging.getLogger(__name__)
|
|
94
|
+
self._api_profile: Optional[OpenCodeApiProfile] = None
|
|
95
|
+
self._api_profile_lock = asyncio.Lock()
|
|
45
96
|
|
|
46
97
|
async def close(self) -> None:
|
|
47
98
|
await self._client.aclose()
|
|
48
99
|
|
|
100
|
+
async def detect_api_shape(self) -> OpenCodeApiProfile:
|
|
101
|
+
"""Detect OpenCode API capabilities by fetching and parsing OpenAPI spec.
|
|
102
|
+
Results are cached for the lifetime of the client instance.
|
|
103
|
+
Thread-safe: multiple concurrent calls will wait for first detection to complete.
|
|
104
|
+
"""
|
|
105
|
+
async with self._api_profile_lock:
|
|
106
|
+
if self._api_profile is not None:
|
|
107
|
+
return self._api_profile
|
|
108
|
+
|
|
109
|
+
profile = OpenCodeApiProfile()
|
|
110
|
+
try:
|
|
111
|
+
spec = await self.fetch_openapi_spec()
|
|
112
|
+
profile.spec_fetched = True
|
|
113
|
+
|
|
114
|
+
if isinstance(spec, dict):
|
|
115
|
+
# Check if /session/{id}/prompt_async exists
|
|
116
|
+
profile.supports_prompt_async = self.has_endpoint(
|
|
117
|
+
spec, "post", "/session/{session_id}/prompt_async"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# Check if /global/* endpoints exist
|
|
121
|
+
profile.supports_global_endpoints = self.has_endpoint(
|
|
122
|
+
spec, "get", "/global/health"
|
|
123
|
+
) or self.has_endpoint(spec, "get", "/global/event")
|
|
124
|
+
|
|
125
|
+
log_event(
|
|
126
|
+
self._logger,
|
|
127
|
+
logging.INFO,
|
|
128
|
+
"opencode.api_shape_detected",
|
|
129
|
+
supports_prompt_async=profile.supports_prompt_async,
|
|
130
|
+
supports_global_endpoints=profile.supports_global_endpoints,
|
|
131
|
+
)
|
|
132
|
+
except Exception as exc:
|
|
133
|
+
self._logger.warning(
|
|
134
|
+
"Failed to detect API shape, assuming modern OpenCode: %s", exc
|
|
135
|
+
)
|
|
136
|
+
# Default to assuming modern OpenCode with all features
|
|
137
|
+
profile.supports_prompt_async = True
|
|
138
|
+
profile.supports_global_endpoints = True
|
|
139
|
+
|
|
140
|
+
self._api_profile = profile
|
|
141
|
+
return profile
|
|
142
|
+
|
|
143
|
+
def _get_api_profile(self) -> OpenCodeApiProfile:
|
|
144
|
+
"""Get API profile, detecting if needed. Synchronous for use in sync methods."""
|
|
145
|
+
if self._api_profile is None:
|
|
146
|
+
# Return default profile if not yet detected
|
|
147
|
+
return OpenCodeApiProfile()
|
|
148
|
+
return self._api_profile
|
|
149
|
+
|
|
49
150
|
def _dir_params(self, directory: Optional[str]) -> dict[str, str]:
|
|
50
151
|
return {"directory": directory} if directory else {}
|
|
51
152
|
|
|
@@ -157,6 +258,14 @@ class OpenCodeClient:
|
|
|
157
258
|
async def get_session(self, session_id: str) -> Any:
|
|
158
259
|
return await self._request("GET", f"/session/{session_id}", expect_json=True)
|
|
159
260
|
|
|
261
|
+
async def session_status(self, *, directory: Optional[str] = None) -> Any:
|
|
262
|
+
return await self._request(
|
|
263
|
+
"GET",
|
|
264
|
+
"/session/status",
|
|
265
|
+
params=self._dir_params(directory),
|
|
266
|
+
expect_json=True,
|
|
267
|
+
)
|
|
268
|
+
|
|
160
269
|
async def send_message(
|
|
161
270
|
self,
|
|
162
271
|
session_id: str,
|
|
@@ -200,22 +309,57 @@ class OpenCodeClient:
|
|
|
200
309
|
payload["model"] = model
|
|
201
310
|
if variant:
|
|
202
311
|
payload["variant"] = variant
|
|
203
|
-
|
|
312
|
+
|
|
313
|
+
profile = await self.detect_api_shape()
|
|
314
|
+
if profile.supports_prompt_async:
|
|
315
|
+
return await self._request(
|
|
316
|
+
"POST",
|
|
317
|
+
f"/session/{session_id}/prompt_async",
|
|
318
|
+
json_body=payload,
|
|
319
|
+
expect_json=False,
|
|
320
|
+
)
|
|
321
|
+
else:
|
|
322
|
+
return await self._request(
|
|
323
|
+
"POST",
|
|
324
|
+
f"/session/{session_id}/message",
|
|
325
|
+
json_body=payload,
|
|
326
|
+
expect_json=True,
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
async def prompt_async(
|
|
330
|
+
self,
|
|
331
|
+
session_id: str,
|
|
332
|
+
*,
|
|
333
|
+
message: str,
|
|
334
|
+
agent: Optional[str] = None,
|
|
335
|
+
model: Optional[dict[str, str]] = None,
|
|
336
|
+
variant: Optional[str] = None,
|
|
337
|
+
) -> Any:
|
|
338
|
+
payload: dict[str, Any] = {
|
|
339
|
+
"parts": [{"type": "text", "text": message}],
|
|
340
|
+
}
|
|
341
|
+
if agent:
|
|
342
|
+
payload["agent"] = agent
|
|
343
|
+
if model:
|
|
344
|
+
payload["model"] = model
|
|
345
|
+
if variant:
|
|
346
|
+
payload["variant"] = variant
|
|
347
|
+
|
|
348
|
+
profile = await self.detect_api_shape()
|
|
349
|
+
if profile.supports_prompt_async:
|
|
350
|
+
return await self._request(
|
|
351
|
+
"POST",
|
|
352
|
+
f"/session/{session_id}/prompt_async",
|
|
353
|
+
json_body=payload,
|
|
354
|
+
expect_json=False,
|
|
355
|
+
)
|
|
356
|
+
else:
|
|
204
357
|
return await self._request(
|
|
205
358
|
"POST",
|
|
206
359
|
f"/session/{session_id}/message",
|
|
207
360
|
json_body=payload,
|
|
208
361
|
expect_json=True,
|
|
209
362
|
)
|
|
210
|
-
except httpx.HTTPStatusError as exc:
|
|
211
|
-
if exc.response.status_code in (404, 405):
|
|
212
|
-
return await self._request(
|
|
213
|
-
"POST",
|
|
214
|
-
f"/session/{session_id}/prompt_async",
|
|
215
|
-
json_body=payload,
|
|
216
|
-
expect_json=False,
|
|
217
|
-
)
|
|
218
|
-
raise
|
|
219
363
|
|
|
220
364
|
async def send_command(
|
|
221
365
|
self,
|
|
@@ -279,31 +423,164 @@ class OpenCodeClient:
|
|
|
279
423
|
expect_json=False,
|
|
280
424
|
)
|
|
281
425
|
|
|
426
|
+
async def list_questions(self) -> Any:
|
|
427
|
+
return await self._request("GET", "/question", expect_json=True)
|
|
428
|
+
|
|
429
|
+
async def reply_question(self, request_id: str, *, answers: list[list[str]]) -> Any:
|
|
430
|
+
payload: dict[str, Any] = {"answers": answers}
|
|
431
|
+
return await self._request(
|
|
432
|
+
"POST",
|
|
433
|
+
f"/question/{request_id}/reply",
|
|
434
|
+
json_body=payload,
|
|
435
|
+
expect_json=False,
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
async def reject_question(self, request_id: str) -> Any:
|
|
439
|
+
return await self._request(
|
|
440
|
+
"POST",
|
|
441
|
+
f"/question/{request_id}/reject",
|
|
442
|
+
expect_json=False,
|
|
443
|
+
)
|
|
444
|
+
|
|
282
445
|
async def abort(self, session_id: str) -> Any:
|
|
283
446
|
return await self._request(
|
|
284
447
|
"POST", f"/session/{session_id}/abort", expect_json=False
|
|
285
448
|
)
|
|
286
449
|
|
|
450
|
+
async def health(self) -> Any:
|
|
451
|
+
"""Check OpenCode server health using /global/health or /health endpoint."""
|
|
452
|
+
profile = await self.detect_api_shape()
|
|
453
|
+
if profile.supports_global_endpoints:
|
|
454
|
+
return await self._request("GET", "/global/health", expect_json=True)
|
|
455
|
+
else:
|
|
456
|
+
return await self._request("GET", "/health", expect_json=True)
|
|
457
|
+
|
|
458
|
+
async def dispose(self, session_id: str) -> Any:
|
|
459
|
+
"""Dispose of a session using /global/dispose/{id} or /session/{id}/dispose endpoint."""
|
|
460
|
+
profile = await self.detect_api_shape()
|
|
461
|
+
if profile.supports_global_endpoints:
|
|
462
|
+
return await self._request(
|
|
463
|
+
"POST", f"/global/dispose/{session_id}", expect_json=False
|
|
464
|
+
)
|
|
465
|
+
else:
|
|
466
|
+
return await self._request(
|
|
467
|
+
"POST", f"/session/{session_id}/dispose", expect_json=False
|
|
468
|
+
)
|
|
469
|
+
|
|
287
470
|
async def stream_events(
|
|
288
|
-
self,
|
|
471
|
+
self,
|
|
472
|
+
*,
|
|
473
|
+
directory: Optional[str] = None,
|
|
474
|
+
ready_event: Optional[asyncio.Event] = None,
|
|
475
|
+
paths: Optional[Iterable[str]] = None,
|
|
289
476
|
) -> AsyncIterator[SSEEvent]:
|
|
290
477
|
params = self._dir_params(directory)
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
yield SSEEvent(
|
|
302
|
-
event=event_type,
|
|
303
|
-
data=sse.data,
|
|
304
|
-
id=sse.id,
|
|
305
|
-
retry=sse.retry,
|
|
478
|
+
|
|
479
|
+
if paths is not None:
|
|
480
|
+
event_paths = list(paths)
|
|
481
|
+
else:
|
|
482
|
+
profile = await self.detect_api_shape()
|
|
483
|
+
if profile.supports_global_endpoints:
|
|
484
|
+
event_paths = (
|
|
485
|
+
["/event", "/global/event"]
|
|
486
|
+
if directory
|
|
487
|
+
else ["/global/event", "/event"]
|
|
306
488
|
)
|
|
489
|
+
else:
|
|
490
|
+
event_paths = ["/event"]
|
|
491
|
+
|
|
492
|
+
last_error: Optional[BaseException] = None
|
|
493
|
+
for path in event_paths:
|
|
494
|
+
try:
|
|
495
|
+
async with self._client.stream(
|
|
496
|
+
"GET", path, params=params, timeout=None
|
|
497
|
+
) as response:
|
|
498
|
+
response.raise_for_status()
|
|
499
|
+
if ready_event is not None:
|
|
500
|
+
ready_event.set()
|
|
501
|
+
async for sse in parse_sse_lines(response.aiter_lines()):
|
|
502
|
+
yield _normalize_sse_event(sse)
|
|
503
|
+
return
|
|
504
|
+
except httpx.HTTPStatusError as exc:
|
|
505
|
+
last_error = exc
|
|
506
|
+
status_code = exc.response.status_code
|
|
507
|
+
if status_code in (404, 405):
|
|
508
|
+
continue
|
|
509
|
+
raise
|
|
510
|
+
except Exception as exc:
|
|
511
|
+
last_error = exc
|
|
512
|
+
raise
|
|
513
|
+
if ready_event is not None and not ready_event.is_set():
|
|
514
|
+
ready_event.set()
|
|
515
|
+
if last_error is not None:
|
|
516
|
+
raise last_error
|
|
517
|
+
|
|
518
|
+
async def fetch_openapi_spec(self) -> dict[str, Any]:
|
|
519
|
+
"""Fetch OpenAPI spec from /doc endpoint for capability negotiation."""
|
|
520
|
+
response = await self._client.get("/doc")
|
|
521
|
+
response.raise_for_status()
|
|
522
|
+
content = response.content
|
|
523
|
+
try:
|
|
524
|
+
spec = json.loads(content) if content else {}
|
|
525
|
+
log_event(
|
|
526
|
+
self._logger,
|
|
527
|
+
logging.INFO,
|
|
528
|
+
"opencode.openapi.fetched",
|
|
529
|
+
paths=len(spec.get("paths", {})) if isinstance(spec, dict) else 0,
|
|
530
|
+
has_components=(
|
|
531
|
+
"components" in spec if isinstance(spec, dict) else False
|
|
532
|
+
),
|
|
533
|
+
)
|
|
534
|
+
return spec
|
|
535
|
+
except Exception as exc:
|
|
536
|
+
log_event(
|
|
537
|
+
self._logger,
|
|
538
|
+
logging.WARNING,
|
|
539
|
+
"opencode.openapi.parse_failed",
|
|
540
|
+
exc=exc,
|
|
541
|
+
)
|
|
542
|
+
raise OpenCodeProtocolError(
|
|
543
|
+
f"Failed to parse OpenAPI spec: {exc}",
|
|
544
|
+
status_code=response.status_code,
|
|
545
|
+
content_type=(
|
|
546
|
+
response.headers.get("content-type") if response else None
|
|
547
|
+
),
|
|
548
|
+
) from exc
|
|
549
|
+
|
|
550
|
+
def has_endpoint(
|
|
551
|
+
self, openapi_spec: dict[str, Any], method: str, path: str
|
|
552
|
+
) -> bool:
|
|
553
|
+
"""Check if endpoint is available in OpenAPI spec.
|
|
554
|
+
|
|
555
|
+
The OpenAPI spec sometimes uses different template parameter names (e.g.,
|
|
556
|
+
`{sessionID}` vs `{session_id}`). We normalize templates before matching so
|
|
557
|
+
capability detection does not depend on placeholder spelling.
|
|
558
|
+
"""
|
|
559
|
+
if not isinstance(openapi_spec, dict):
|
|
560
|
+
return False
|
|
561
|
+
paths = openapi_spec.get("paths", {})
|
|
562
|
+
if not isinstance(paths, dict):
|
|
563
|
+
return False
|
|
564
|
+
|
|
565
|
+
target = _normalize_template_path(path)
|
|
566
|
+
method = method.lower()
|
|
567
|
+
|
|
568
|
+
for candidate_path, info in paths.items():
|
|
569
|
+
if not isinstance(info, dict):
|
|
570
|
+
continue
|
|
571
|
+
if _normalize_template_path(candidate_path) != target:
|
|
572
|
+
continue
|
|
573
|
+
if method in info:
|
|
574
|
+
return True
|
|
575
|
+
return False
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _normalize_template_path(path: str) -> str:
|
|
579
|
+
"""Collapse template placeholders to a canonical form.
|
|
580
|
+
|
|
581
|
+
Example: `/session/{sessionID}/prompt_async` -> `/session/{}/prompt_async`
|
|
582
|
+
"""
|
|
583
|
+
return re.sub(r"{[^/]+}", "{}", path)
|
|
307
584
|
|
|
308
585
|
|
|
309
|
-
__all__ = ["OpenCodeClient", "OpenCodeProtocolError"]
|
|
586
|
+
__all__ = ["OpenCodeClient", "OpenCodeProtocolError", "OpenCodeApiProfile"]
|