codex-autorunner 1.0.0__py3-none-any.whl → 1.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codex_autorunner/__init__.py +12 -1
- codex_autorunner/agents/codex/harness.py +1 -1
- codex_autorunner/agents/opencode/client.py +113 -4
- codex_autorunner/agents/opencode/constants.py +3 -0
- codex_autorunner/agents/opencode/harness.py +6 -1
- codex_autorunner/agents/opencode/runtime.py +59 -18
- codex_autorunner/agents/opencode/supervisor.py +4 -0
- codex_autorunner/agents/registry.py +36 -7
- codex_autorunner/bootstrap.py +226 -4
- codex_autorunner/cli.py +5 -1174
- codex_autorunner/codex_cli.py +20 -84
- codex_autorunner/core/__init__.py +20 -0
- codex_autorunner/core/about_car.py +119 -1
- codex_autorunner/core/app_server_ids.py +59 -0
- codex_autorunner/core/app_server_threads.py +17 -2
- codex_autorunner/core/app_server_utils.py +165 -0
- codex_autorunner/core/archive.py +349 -0
- codex_autorunner/core/codex_runner.py +6 -2
- codex_autorunner/core/config.py +433 -4
- codex_autorunner/core/context_awareness.py +38 -0
- codex_autorunner/core/docs.py +0 -122
- codex_autorunner/core/drafts.py +58 -4
- codex_autorunner/core/exceptions.py +4 -0
- codex_autorunner/core/filebox.py +265 -0
- codex_autorunner/core/flows/controller.py +96 -2
- codex_autorunner/core/flows/models.py +13 -0
- codex_autorunner/core/flows/reasons.py +52 -0
- codex_autorunner/core/flows/reconciler.py +134 -0
- codex_autorunner/core/flows/runtime.py +57 -4
- codex_autorunner/core/flows/store.py +142 -7
- codex_autorunner/core/flows/transition.py +27 -15
- codex_autorunner/core/flows/ux_helpers.py +272 -0
- codex_autorunner/core/flows/worker_process.py +32 -6
- codex_autorunner/core/git_utils.py +62 -0
- codex_autorunner/core/hub.py +291 -20
- codex_autorunner/core/lifecycle_events.py +253 -0
- codex_autorunner/core/notifications.py +14 -2
- codex_autorunner/core/path_utils.py +2 -1
- codex_autorunner/core/pma_audit.py +224 -0
- codex_autorunner/core/pma_context.py +496 -0
- codex_autorunner/core/pma_dispatch_interceptor.py +284 -0
- codex_autorunner/core/pma_lifecycle.py +527 -0
- codex_autorunner/core/pma_queue.py +367 -0
- codex_autorunner/core/pma_safety.py +221 -0
- codex_autorunner/core/pma_state.py +115 -0
- codex_autorunner/core/ports/__init__.py +28 -0
- codex_autorunner/{integrations/agents → core/ports}/agent_backend.py +13 -8
- codex_autorunner/core/ports/backend_orchestrator.py +41 -0
- codex_autorunner/{integrations/agents → core/ports}/run_event.py +23 -6
- codex_autorunner/core/prompt.py +0 -80
- codex_autorunner/core/prompts.py +56 -172
- codex_autorunner/core/redaction.py +0 -4
- codex_autorunner/core/review_context.py +11 -9
- codex_autorunner/core/runner_controller.py +35 -33
- codex_autorunner/core/runner_state.py +147 -0
- codex_autorunner/core/runtime.py +829 -0
- codex_autorunner/core/sqlite_utils.py +13 -4
- codex_autorunner/core/state.py +7 -10
- codex_autorunner/core/state_roots.py +62 -0
- codex_autorunner/core/supervisor_protocol.py +15 -0
- codex_autorunner/core/templates/__init__.py +39 -0
- codex_autorunner/core/templates/git_mirror.py +234 -0
- codex_autorunner/core/templates/provenance.py +56 -0
- codex_autorunner/core/templates/scan_cache.py +120 -0
- codex_autorunner/core/text_delta_coalescer.py +54 -0
- codex_autorunner/core/ticket_linter_cli.py +218 -0
- codex_autorunner/core/ticket_manager_cli.py +494 -0
- codex_autorunner/core/time_utils.py +11 -0
- codex_autorunner/core/types.py +18 -0
- codex_autorunner/core/update.py +4 -5
- codex_autorunner/core/update_paths.py +28 -0
- codex_autorunner/core/usage.py +164 -12
- codex_autorunner/core/utils.py +125 -15
- codex_autorunner/flows/review/__init__.py +17 -0
- codex_autorunner/{core/review.py → flows/review/service.py} +37 -34
- codex_autorunner/flows/ticket_flow/definition.py +52 -3
- codex_autorunner/integrations/agents/__init__.py +11 -19
- codex_autorunner/integrations/agents/backend_orchestrator.py +302 -0
- codex_autorunner/integrations/agents/codex_adapter.py +90 -0
- codex_autorunner/integrations/agents/codex_backend.py +177 -25
- codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
- codex_autorunner/integrations/agents/opencode_backend.py +305 -32
- codex_autorunner/integrations/agents/runner.py +86 -0
- codex_autorunner/integrations/agents/wiring.py +279 -0
- codex_autorunner/integrations/app_server/client.py +7 -60
- codex_autorunner/integrations/app_server/env.py +2 -107
- codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
- codex_autorunner/integrations/telegram/adapter.py +65 -0
- codex_autorunner/integrations/telegram/config.py +46 -0
- codex_autorunner/integrations/telegram/constants.py +1 -1
- codex_autorunner/integrations/telegram/doctor.py +228 -6
- codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -0
- codex_autorunner/integrations/telegram/handlers/commands/execution.py +236 -74
- codex_autorunner/integrations/telegram/handlers/commands/files.py +314 -75
- codex_autorunner/integrations/telegram/handlers/commands/flows.py +1496 -71
- codex_autorunner/integrations/telegram/handlers/commands/workspace.py +498 -37
- codex_autorunner/integrations/telegram/handlers/commands_runtime.py +206 -48
- codex_autorunner/integrations/telegram/handlers/commands_spec.py +20 -3
- codex_autorunner/integrations/telegram/handlers/messages.py +27 -1
- codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
- codex_autorunner/integrations/telegram/helpers.py +22 -1
- codex_autorunner/integrations/telegram/runtime.py +9 -4
- codex_autorunner/integrations/telegram/service.py +45 -10
- codex_autorunner/integrations/telegram/state.py +38 -0
- codex_autorunner/integrations/telegram/ticket_flow_bridge.py +338 -43
- codex_autorunner/integrations/telegram/transport.py +13 -4
- codex_autorunner/integrations/templates/__init__.py +27 -0
- codex_autorunner/integrations/templates/scan_agent.py +312 -0
- codex_autorunner/routes/__init__.py +37 -76
- codex_autorunner/routes/agents.py +2 -137
- codex_autorunner/routes/analytics.py +2 -238
- codex_autorunner/routes/app_server.py +2 -131
- codex_autorunner/routes/base.py +2 -596
- codex_autorunner/routes/file_chat.py +4 -833
- codex_autorunner/routes/flows.py +4 -977
- codex_autorunner/routes/messages.py +4 -456
- codex_autorunner/routes/repos.py +2 -196
- codex_autorunner/routes/review.py +2 -147
- codex_autorunner/routes/sessions.py +2 -175
- codex_autorunner/routes/settings.py +2 -168
- codex_autorunner/routes/shared.py +2 -275
- codex_autorunner/routes/system.py +4 -193
- codex_autorunner/routes/usage.py +2 -86
- codex_autorunner/routes/voice.py +2 -119
- codex_autorunner/routes/workspace.py +2 -270
- codex_autorunner/server.py +4 -4
- codex_autorunner/static/agentControls.js +61 -16
- codex_autorunner/static/app.js +126 -14
- codex_autorunner/static/archive.js +826 -0
- codex_autorunner/static/archiveApi.js +37 -0
- codex_autorunner/static/autoRefresh.js +7 -7
- codex_autorunner/static/chatUploads.js +137 -0
- codex_autorunner/static/dashboard.js +224 -171
- codex_autorunner/static/docChatCore.js +185 -13
- codex_autorunner/static/fileChat.js +68 -40
- codex_autorunner/static/fileboxUi.js +159 -0
- codex_autorunner/static/hub.js +114 -131
- codex_autorunner/static/index.html +375 -49
- codex_autorunner/static/messages.js +568 -87
- codex_autorunner/static/notifications.js +255 -0
- codex_autorunner/static/pma.js +1167 -0
- codex_autorunner/static/preserve.js +17 -0
- codex_autorunner/static/settings.js +128 -6
- codex_autorunner/static/smartRefresh.js +52 -0
- codex_autorunner/static/streamUtils.js +57 -0
- codex_autorunner/static/styles.css +9798 -6143
- codex_autorunner/static/tabs.js +152 -11
- codex_autorunner/static/templateReposSettings.js +225 -0
- codex_autorunner/static/terminal.js +18 -0
- codex_autorunner/static/ticketChatActions.js +165 -3
- codex_autorunner/static/ticketChatStream.js +17 -119
- codex_autorunner/static/ticketEditor.js +137 -15
- codex_autorunner/static/ticketTemplates.js +798 -0
- codex_autorunner/static/tickets.js +821 -98
- codex_autorunner/static/turnEvents.js +27 -0
- codex_autorunner/static/turnResume.js +33 -0
- codex_autorunner/static/utils.js +39 -0
- codex_autorunner/static/workspace.js +389 -82
- codex_autorunner/static/workspaceFileBrowser.js +15 -13
- codex_autorunner/surfaces/__init__.py +5 -0
- codex_autorunner/surfaces/cli/__init__.py +6 -0
- codex_autorunner/surfaces/cli/cli.py +2534 -0
- codex_autorunner/surfaces/cli/codex_cli.py +20 -0
- codex_autorunner/surfaces/cli/pma_cli.py +817 -0
- codex_autorunner/surfaces/telegram/__init__.py +3 -0
- codex_autorunner/surfaces/web/__init__.py +1 -0
- codex_autorunner/surfaces/web/app.py +2223 -0
- codex_autorunner/surfaces/web/hub_jobs.py +192 -0
- codex_autorunner/surfaces/web/middleware.py +587 -0
- codex_autorunner/surfaces/web/pty_session.py +370 -0
- codex_autorunner/surfaces/web/review.py +6 -0
- codex_autorunner/surfaces/web/routes/__init__.py +82 -0
- codex_autorunner/surfaces/web/routes/agents.py +138 -0
- codex_autorunner/surfaces/web/routes/analytics.py +284 -0
- codex_autorunner/surfaces/web/routes/app_server.py +132 -0
- codex_autorunner/surfaces/web/routes/archive.py +357 -0
- codex_autorunner/surfaces/web/routes/base.py +615 -0
- codex_autorunner/surfaces/web/routes/file_chat.py +1117 -0
- codex_autorunner/surfaces/web/routes/filebox.py +227 -0
- codex_autorunner/surfaces/web/routes/flows.py +1354 -0
- codex_autorunner/surfaces/web/routes/messages.py +490 -0
- codex_autorunner/surfaces/web/routes/pma.py +1652 -0
- codex_autorunner/surfaces/web/routes/repos.py +197 -0
- codex_autorunner/surfaces/web/routes/review.py +148 -0
- codex_autorunner/surfaces/web/routes/sessions.py +176 -0
- codex_autorunner/surfaces/web/routes/settings.py +169 -0
- codex_autorunner/surfaces/web/routes/shared.py +277 -0
- codex_autorunner/surfaces/web/routes/system.py +196 -0
- codex_autorunner/surfaces/web/routes/templates.py +634 -0
- codex_autorunner/surfaces/web/routes/usage.py +89 -0
- codex_autorunner/surfaces/web/routes/voice.py +120 -0
- codex_autorunner/surfaces/web/routes/workspace.py +271 -0
- codex_autorunner/surfaces/web/runner_manager.py +25 -0
- codex_autorunner/surfaces/web/schemas.py +469 -0
- codex_autorunner/surfaces/web/static_assets.py +490 -0
- codex_autorunner/surfaces/web/static_refresh.py +86 -0
- codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
- codex_autorunner/tickets/__init__.py +8 -1
- codex_autorunner/tickets/agent_pool.py +53 -4
- codex_autorunner/tickets/files.py +37 -16
- codex_autorunner/tickets/lint.py +50 -0
- codex_autorunner/tickets/models.py +6 -1
- codex_autorunner/tickets/outbox.py +50 -2
- codex_autorunner/tickets/runner.py +396 -57
- codex_autorunner/web/__init__.py +5 -1
- codex_autorunner/web/app.py +2 -1949
- codex_autorunner/web/hub_jobs.py +2 -191
- codex_autorunner/web/middleware.py +2 -586
- codex_autorunner/web/pty_session.py +2 -369
- codex_autorunner/web/runner_manager.py +2 -24
- codex_autorunner/web/schemas.py +2 -376
- codex_autorunner/web/static_assets.py +4 -441
- codex_autorunner/web/static_refresh.py +2 -85
- codex_autorunner/web/terminal_sessions.py +2 -77
- codex_autorunner/workspace/paths.py +49 -33
- codex_autorunner-1.2.0.dist-info/METADATA +150 -0
- codex_autorunner-1.2.0.dist-info/RECORD +339 -0
- codex_autorunner/core/adapter_utils.py +0 -21
- codex_autorunner/core/engine.py +0 -2653
- codex_autorunner/core/static_assets.py +0 -55
- codex_autorunner-1.0.0.dist-info/METADATA +0 -246
- codex_autorunner-1.0.0.dist-info/RECORD +0 -251
- /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
- {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/WHEEL +0 -0
- {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/entry_points.txt +0 -0
- {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/licenses/LICENSE +0 -0
- {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,1117 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unified file chat routes: AI-powered editing for tickets and workspace docs.
|
|
3
|
+
|
|
4
|
+
Targets:
|
|
5
|
+
- ticket:{index} -> .codex-autorunner/tickets/TICKET-###.md
|
|
6
|
+
- workspace:{path} -> .codex-autorunner/workspace/{path}
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import contextlib
|
|
13
|
+
import difflib
|
|
14
|
+
import logging
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, AsyncIterator, Callable, Dict, Optional
|
|
18
|
+
|
|
19
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
20
|
+
from fastapi.responses import StreamingResponse
|
|
21
|
+
|
|
22
|
+
from ....agents.registry import validate_agent_id
|
|
23
|
+
from ....core import drafts as draft_utils
|
|
24
|
+
from ....core.state import now_iso
|
|
25
|
+
from ....core.utils import atomic_write, find_repo_root
|
|
26
|
+
from ....integrations.app_server.event_buffer import format_sse
|
|
27
|
+
from ....workspace.paths import (
|
|
28
|
+
WORKSPACE_DOC_KINDS,
|
|
29
|
+
normalize_workspace_rel_path,
|
|
30
|
+
workspace_doc_path,
|
|
31
|
+
)
|
|
32
|
+
from .shared import SSE_HEADERS
|
|
33
|
+
|
|
34
|
+
FILE_CHAT_STATE_NAME = draft_utils.FILE_CHAT_STATE_NAME
|
|
35
|
+
FILE_CHAT_TIMEOUT_SECONDS = 180
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class FileChatError(Exception):
|
|
40
|
+
"""Base error for file chat failures."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class _Target:
|
|
45
|
+
target: str
|
|
46
|
+
kind: str # "ticket" | "workspace"
|
|
47
|
+
id: str # "001" | "spec"
|
|
48
|
+
path: Path
|
|
49
|
+
rel_path: str
|
|
50
|
+
state_key: str
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _state_path(repo_root: Path) -> Path:
|
|
54
|
+
return draft_utils.state_path(repo_root)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _load_state(repo_root: Path) -> Dict[str, Any]:
|
|
58
|
+
return draft_utils.load_state(repo_root)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _save_state(repo_root: Path, state: Dict[str, Any]) -> None:
|
|
62
|
+
draft_utils.save_state(repo_root, state)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _hash_content(content: str) -> str:
|
|
66
|
+
return draft_utils.hash_content(content)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _resolve_repo_root(request: Optional[Request] = None) -> Path:
|
|
70
|
+
if request is not None:
|
|
71
|
+
engine = getattr(request.app.state, "engine", None)
|
|
72
|
+
repo_root = getattr(engine, "repo_root", None)
|
|
73
|
+
if isinstance(repo_root, Path):
|
|
74
|
+
return repo_root
|
|
75
|
+
if isinstance(repo_root, str):
|
|
76
|
+
try:
|
|
77
|
+
return Path(repo_root)
|
|
78
|
+
except Exception:
|
|
79
|
+
pass
|
|
80
|
+
return find_repo_root()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _ticket_path(repo_root: Path, index: int) -> Path:
|
|
84
|
+
return repo_root / ".codex-autorunner" / "tickets" / f"TICKET-{index:03d}.md"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _parse_target(repo_root: Path, raw: str) -> _Target:
|
|
88
|
+
target = (raw or "").strip()
|
|
89
|
+
if not target:
|
|
90
|
+
raise HTTPException(status_code=400, detail="target is required")
|
|
91
|
+
|
|
92
|
+
if target.lower().startswith("ticket:"):
|
|
93
|
+
suffix = target.split(":", 1)[1].strip()
|
|
94
|
+
if not suffix.isdigit():
|
|
95
|
+
raise HTTPException(status_code=400, detail="invalid ticket target")
|
|
96
|
+
idx = int(suffix)
|
|
97
|
+
if idx <= 0:
|
|
98
|
+
raise HTTPException(status_code=400, detail="invalid ticket target")
|
|
99
|
+
path = _ticket_path(repo_root, idx)
|
|
100
|
+
rel = (
|
|
101
|
+
str(path.relative_to(repo_root))
|
|
102
|
+
if path.is_relative_to(repo_root)
|
|
103
|
+
else str(path)
|
|
104
|
+
)
|
|
105
|
+
return _Target(
|
|
106
|
+
target=f"ticket:{idx}",
|
|
107
|
+
kind="ticket",
|
|
108
|
+
id=f"{idx:03d}",
|
|
109
|
+
path=path,
|
|
110
|
+
rel_path=rel,
|
|
111
|
+
state_key=f"ticket_{idx:03d}",
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
if target.lower().startswith("workspace:"):
|
|
115
|
+
suffix_raw = target.split(":", 1)[1].strip()
|
|
116
|
+
if not suffix_raw:
|
|
117
|
+
raise HTTPException(status_code=400, detail="invalid workspace target")
|
|
118
|
+
|
|
119
|
+
# Allow legacy kind-only targets (active_context/decisions/spec)
|
|
120
|
+
if suffix_raw.lower() in WORKSPACE_DOC_KINDS:
|
|
121
|
+
path = workspace_doc_path(repo_root, suffix_raw)
|
|
122
|
+
rel_suffix = f"{suffix_raw}.md"
|
|
123
|
+
else:
|
|
124
|
+
try:
|
|
125
|
+
path, rel_suffix = normalize_workspace_rel_path(repo_root, suffix_raw)
|
|
126
|
+
except ValueError as exc:
|
|
127
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
128
|
+
|
|
129
|
+
rel = (
|
|
130
|
+
str(path.relative_to(repo_root))
|
|
131
|
+
if path.is_relative_to(repo_root)
|
|
132
|
+
else str(path)
|
|
133
|
+
)
|
|
134
|
+
return _Target(
|
|
135
|
+
target=f"workspace:{rel_suffix}",
|
|
136
|
+
kind="workspace",
|
|
137
|
+
id=rel_suffix,
|
|
138
|
+
path=path,
|
|
139
|
+
rel_path=rel,
|
|
140
|
+
state_key=f"workspace_{rel_suffix.replace('/', '_')}",
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
raise HTTPException(status_code=400, detail=f"invalid target: {target}")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _build_file_chat_prompt(*, target: _Target, message: str, before: str) -> str:
|
|
147
|
+
if target.kind == "ticket":
|
|
148
|
+
file_role_context = (
|
|
149
|
+
"This file is a CAR ticket. Ticket flow processes "
|
|
150
|
+
"`.codex-autorunner/tickets/TICKET-###*.md` in numeric order.\n"
|
|
151
|
+
"Edits here change what the ticket flow agent will do; keep YAML "
|
|
152
|
+
"frontmatter valid."
|
|
153
|
+
)
|
|
154
|
+
elif target.kind == "workspace":
|
|
155
|
+
file_role_context = (
|
|
156
|
+
"This file is a CAR workspace doc under `.codex-autorunner/workspace/`."
|
|
157
|
+
" These docs act as shared memory across ticket turns."
|
|
158
|
+
)
|
|
159
|
+
else:
|
|
160
|
+
file_role_context = (
|
|
161
|
+
"This file is a normal repo file (not a CAR ticket/workspace doc)."
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
"<injected context>\n"
|
|
166
|
+
"You are operating inside a Codex Autorunner (CAR) managed repo.\n\n"
|
|
167
|
+
"CAR’s durable control-plane lives under `.codex-autorunner/`:\n"
|
|
168
|
+
"- `.codex-autorunner/ABOUT_CAR.md` — short repo-local briefing "
|
|
169
|
+
"(ticket/workspace conventions + helper scripts).\n"
|
|
170
|
+
"- `.codex-autorunner/tickets/` — ordered ticket queue "
|
|
171
|
+
"(`TICKET-###*.md`) used by the ticket flow runner.\n"
|
|
172
|
+
"- `.codex-autorunner/workspace/` — shared context docs:\n"
|
|
173
|
+
" - `active_context.md` — current “north star” context; kept fresh "
|
|
174
|
+
"for ongoing work.\n"
|
|
175
|
+
" - `spec.md` — longer spec / acceptance criteria when needed.\n"
|
|
176
|
+
" - `decisions.md` — prior decisions / tradeoffs when relevant.\n\n"
|
|
177
|
+
"Intent signals: if the user mentions tickets, “dispatch”, “resume”, "
|
|
178
|
+
"workspace docs, or `.codex-autorunner/`, they are likely referring "
|
|
179
|
+
"to CAR artifacts/workflow rather than generic repo files.\n\n"
|
|
180
|
+
"Use the above as orientation. If you need the operational details "
|
|
181
|
+
"(exact helper commands, what CAR auto-generates), read "
|
|
182
|
+
"`.codex-autorunner/ABOUT_CAR.md`.\n"
|
|
183
|
+
"</injected context>\n\n"
|
|
184
|
+
"<file_role_context>\n"
|
|
185
|
+
f"{file_role_context}\n"
|
|
186
|
+
"</file_role_context>\n\n"
|
|
187
|
+
"You are editing a single file in Codex Autorunner.\n\n"
|
|
188
|
+
"<target>\n"
|
|
189
|
+
f"{target.target}\n"
|
|
190
|
+
"</target>\n\n"
|
|
191
|
+
"<path>\n"
|
|
192
|
+
f"{target.rel_path}\n"
|
|
193
|
+
"</path>\n\n"
|
|
194
|
+
"<instructions>\n"
|
|
195
|
+
"- This is a single-turn edit request. Don’t ask the user questions.\n"
|
|
196
|
+
"- You may read other files for context, but only modify the target file.\n"
|
|
197
|
+
"- If no changes are needed, explain why without editing the file.\n"
|
|
198
|
+
"- Respond with a short summary of what you did.\n"
|
|
199
|
+
"</instructions>\n\n"
|
|
200
|
+
"<user_request>\n"
|
|
201
|
+
f"{message}\n"
|
|
202
|
+
"</user_request>\n\n"
|
|
203
|
+
"<FILE_CONTENT>\n"
|
|
204
|
+
f"{before[:12000]}\n"
|
|
205
|
+
"</FILE_CONTENT>\n"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _read_file(path: Path) -> str:
|
|
210
|
+
if not path.exists():
|
|
211
|
+
return ""
|
|
212
|
+
return path.read_text(encoding="utf-8")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _build_patch(rel_path: str, before: str, after: str) -> str:
|
|
216
|
+
diff = difflib.unified_diff(
|
|
217
|
+
before.splitlines(),
|
|
218
|
+
after.splitlines(),
|
|
219
|
+
fromfile=f"a/{rel_path}",
|
|
220
|
+
tofile=f"b/{rel_path}",
|
|
221
|
+
lineterm="",
|
|
222
|
+
)
|
|
223
|
+
return "\n".join(diff)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def build_file_chat_routes() -> APIRouter:
|
|
227
|
+
router = APIRouter(prefix="/api", tags=["file-chat"])
|
|
228
|
+
_active_chats: Dict[str, asyncio.Event] = {}
|
|
229
|
+
_chat_lock = asyncio.Lock()
|
|
230
|
+
_turn_lock = asyncio.Lock()
|
|
231
|
+
_current_by_target: Dict[str, Dict[str, Any]] = {}
|
|
232
|
+
_current_by_client: Dict[str, Dict[str, Any]] = {}
|
|
233
|
+
_last_by_client: Dict[str, Dict[str, Any]] = {}
|
|
234
|
+
|
|
235
|
+
async def _get_or_create_interrupt_event(key: str) -> asyncio.Event:
|
|
236
|
+
async with _chat_lock:
|
|
237
|
+
if key not in _active_chats:
|
|
238
|
+
_active_chats[key] = asyncio.Event()
|
|
239
|
+
return _active_chats[key]
|
|
240
|
+
|
|
241
|
+
async def _clear_interrupt_event(key: str) -> None:
|
|
242
|
+
async with _chat_lock:
|
|
243
|
+
_active_chats.pop(key, None)
|
|
244
|
+
|
|
245
|
+
async def _begin_turn_state(target: _Target, client_turn_id: Optional[str]) -> None:
|
|
246
|
+
async with _turn_lock:
|
|
247
|
+
state: Dict[str, Any] = {
|
|
248
|
+
"client_turn_id": client_turn_id or "",
|
|
249
|
+
"target": target.target,
|
|
250
|
+
"status": "starting",
|
|
251
|
+
"agent": None,
|
|
252
|
+
"thread_id": None,
|
|
253
|
+
"turn_id": None,
|
|
254
|
+
}
|
|
255
|
+
_current_by_target[target.state_key] = state
|
|
256
|
+
if client_turn_id:
|
|
257
|
+
_current_by_client[client_turn_id] = state
|
|
258
|
+
|
|
259
|
+
async def _update_turn_state(target: _Target, **updates: Any) -> None:
|
|
260
|
+
async with _turn_lock:
|
|
261
|
+
state = _current_by_target.get(target.state_key)
|
|
262
|
+
if not state:
|
|
263
|
+
return
|
|
264
|
+
for key, value in updates.items():
|
|
265
|
+
if value is None:
|
|
266
|
+
continue
|
|
267
|
+
state[key] = value
|
|
268
|
+
cid = state.get("client_turn_id") or ""
|
|
269
|
+
if cid:
|
|
270
|
+
_current_by_client[cid] = state
|
|
271
|
+
|
|
272
|
+
async def _finalize_turn_state(target: _Target, result: Dict[str, Any]) -> None:
|
|
273
|
+
async with _turn_lock:
|
|
274
|
+
state = _current_by_target.pop(target.state_key, None)
|
|
275
|
+
cid = ""
|
|
276
|
+
if state:
|
|
277
|
+
cid = state.get("client_turn_id", "") or ""
|
|
278
|
+
if cid:
|
|
279
|
+
_current_by_client.pop(cid, None)
|
|
280
|
+
_last_by_client[cid] = dict(result or {})
|
|
281
|
+
|
|
282
|
+
async def _active_for_client(client_turn_id: Optional[str]) -> Dict[str, Any]:
|
|
283
|
+
if not client_turn_id:
|
|
284
|
+
return {}
|
|
285
|
+
async with _turn_lock:
|
|
286
|
+
return dict(_current_by_client.get(client_turn_id, {}))
|
|
287
|
+
|
|
288
|
+
async def _last_for_client(client_turn_id: Optional[str]) -> Dict[str, Any]:
|
|
289
|
+
if not client_turn_id:
|
|
290
|
+
return {}
|
|
291
|
+
async with _turn_lock:
|
|
292
|
+
return dict(_last_by_client.get(client_turn_id, {}))
|
|
293
|
+
|
|
294
|
+
@router.get("/file-chat/active")
|
|
295
|
+
async def file_chat_active(client_turn_id: Optional[str] = None) -> Dict[str, Any]:
|
|
296
|
+
current = await _active_for_client(client_turn_id)
|
|
297
|
+
last = await _last_for_client(client_turn_id)
|
|
298
|
+
return {"active": bool(current), "current": current, "last_result": last}
|
|
299
|
+
|
|
300
|
+
@router.post("/file-chat")
|
|
301
|
+
async def chat_file(request: Request):
|
|
302
|
+
"""Chat with a file target - optionally streams SSE events."""
|
|
303
|
+
body = await request.json()
|
|
304
|
+
target_raw = body.get("target")
|
|
305
|
+
message = (body.get("message") or "").strip()
|
|
306
|
+
stream = bool(body.get("stream", False))
|
|
307
|
+
agent = body.get("agent", "codex")
|
|
308
|
+
model = body.get("model")
|
|
309
|
+
reasoning = body.get("reasoning")
|
|
310
|
+
client_turn_id = (body.get("client_turn_id") or "").strip() or None
|
|
311
|
+
|
|
312
|
+
if not message:
|
|
313
|
+
raise HTTPException(status_code=400, detail="message is required")
|
|
314
|
+
|
|
315
|
+
repo_root = _resolve_repo_root(request)
|
|
316
|
+
target = _parse_target(repo_root, str(target_raw or ""))
|
|
317
|
+
|
|
318
|
+
# Ensure target directory exists for workspace docs (write on demand)
|
|
319
|
+
if target.kind == "workspace":
|
|
320
|
+
target.path.parent.mkdir(parents=True, exist_ok=True)
|
|
321
|
+
|
|
322
|
+
# Concurrency guard per target
|
|
323
|
+
async with _chat_lock:
|
|
324
|
+
existing = _active_chats.get(target.state_key)
|
|
325
|
+
if existing is not None and not existing.is_set():
|
|
326
|
+
raise HTTPException(status_code=409, detail="File chat already running")
|
|
327
|
+
_active_chats[target.state_key] = asyncio.Event()
|
|
328
|
+
|
|
329
|
+
await _begin_turn_state(target, client_turn_id)
|
|
330
|
+
|
|
331
|
+
if stream:
|
|
332
|
+
return StreamingResponse(
|
|
333
|
+
_stream_file_chat(
|
|
334
|
+
request,
|
|
335
|
+
repo_root,
|
|
336
|
+
target,
|
|
337
|
+
message,
|
|
338
|
+
agent=agent,
|
|
339
|
+
model=model,
|
|
340
|
+
reasoning=reasoning,
|
|
341
|
+
client_turn_id=client_turn_id,
|
|
342
|
+
),
|
|
343
|
+
media_type="text/event-stream",
|
|
344
|
+
headers=SSE_HEADERS,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
try:
|
|
348
|
+
result: Dict[str, Any]
|
|
349
|
+
|
|
350
|
+
async def _on_meta(agent_id: str, thread_id: str, turn_id: str) -> None:
|
|
351
|
+
await _update_turn_state(
|
|
352
|
+
target,
|
|
353
|
+
agent=agent_id,
|
|
354
|
+
thread_id=thread_id,
|
|
355
|
+
turn_id=turn_id,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
try:
|
|
359
|
+
result = await _execute_file_chat(
|
|
360
|
+
request,
|
|
361
|
+
repo_root,
|
|
362
|
+
target,
|
|
363
|
+
message,
|
|
364
|
+
agent=agent,
|
|
365
|
+
model=model,
|
|
366
|
+
reasoning=reasoning,
|
|
367
|
+
on_meta=_on_meta,
|
|
368
|
+
)
|
|
369
|
+
except Exception as exc:
|
|
370
|
+
await _finalize_turn_state(
|
|
371
|
+
target,
|
|
372
|
+
{
|
|
373
|
+
"status": "error",
|
|
374
|
+
"detail": str(exc),
|
|
375
|
+
"client_turn_id": client_turn_id or "",
|
|
376
|
+
},
|
|
377
|
+
)
|
|
378
|
+
raise
|
|
379
|
+
result = dict(result or {})
|
|
380
|
+
result["client_turn_id"] = client_turn_id or ""
|
|
381
|
+
await _finalize_turn_state(target, result)
|
|
382
|
+
return result
|
|
383
|
+
finally:
|
|
384
|
+
await _clear_interrupt_event(target.state_key)
|
|
385
|
+
|
|
386
|
+
async def _stream_file_chat(
|
|
387
|
+
request: Request,
|
|
388
|
+
repo_root: Path,
|
|
389
|
+
target: _Target,
|
|
390
|
+
message: str,
|
|
391
|
+
*,
|
|
392
|
+
agent: str = "codex",
|
|
393
|
+
model: Optional[str] = None,
|
|
394
|
+
reasoning: Optional[str] = None,
|
|
395
|
+
client_turn_id: Optional[str] = None,
|
|
396
|
+
) -> AsyncIterator[str]:
|
|
397
|
+
yield format_sse("status", {"status": "queued"})
|
|
398
|
+
try:
|
|
399
|
+
|
|
400
|
+
async def _on_meta(agent_id: str, thread_id: str, turn_id: str) -> None:
|
|
401
|
+
await _update_turn_state(
|
|
402
|
+
target,
|
|
403
|
+
agent=agent_id,
|
|
404
|
+
thread_id=thread_id,
|
|
405
|
+
turn_id=turn_id,
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
run_task = asyncio.create_task(
|
|
409
|
+
_execute_file_chat(
|
|
410
|
+
request,
|
|
411
|
+
repo_root,
|
|
412
|
+
target,
|
|
413
|
+
message,
|
|
414
|
+
agent=agent,
|
|
415
|
+
model=model,
|
|
416
|
+
reasoning=reasoning,
|
|
417
|
+
on_meta=_on_meta,
|
|
418
|
+
)
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
async def _finalize() -> None:
|
|
422
|
+
result = {"status": "error", "detail": "File chat failed"}
|
|
423
|
+
try:
|
|
424
|
+
result = await run_task
|
|
425
|
+
except Exception as exc:
|
|
426
|
+
logger.exception("file chat task failed")
|
|
427
|
+
result = {
|
|
428
|
+
"status": "error",
|
|
429
|
+
"detail": str(exc) or "File chat failed",
|
|
430
|
+
}
|
|
431
|
+
result = dict(result or {})
|
|
432
|
+
result["client_turn_id"] = client_turn_id or ""
|
|
433
|
+
await _finalize_turn_state(target, result)
|
|
434
|
+
|
|
435
|
+
asyncio.create_task(_finalize())
|
|
436
|
+
|
|
437
|
+
try:
|
|
438
|
+
result = await asyncio.shield(run_task)
|
|
439
|
+
except asyncio.CancelledError:
|
|
440
|
+
# client disconnected; turn continues in background
|
|
441
|
+
return
|
|
442
|
+
|
|
443
|
+
if result.get("status") == "ok":
|
|
444
|
+
raw_events = result.pop("raw_events", []) or []
|
|
445
|
+
for event in raw_events:
|
|
446
|
+
yield format_sse("app-server", event)
|
|
447
|
+
usage_parts = result.pop("usage_parts", []) or []
|
|
448
|
+
for usage in usage_parts:
|
|
449
|
+
yield format_sse("token_usage", usage)
|
|
450
|
+
result["client_turn_id"] = client_turn_id or ""
|
|
451
|
+
yield format_sse("update", result)
|
|
452
|
+
yield format_sse("done", {"status": "ok"})
|
|
453
|
+
elif result.get("status") == "interrupted":
|
|
454
|
+
yield format_sse(
|
|
455
|
+
"interrupted",
|
|
456
|
+
{"detail": result.get("detail") or "File chat interrupted"},
|
|
457
|
+
)
|
|
458
|
+
else:
|
|
459
|
+
yield format_sse(
|
|
460
|
+
"error", {"detail": result.get("detail") or "File chat failed"}
|
|
461
|
+
)
|
|
462
|
+
except Exception:
|
|
463
|
+
logger.exception("file chat stream failed")
|
|
464
|
+
yield format_sse("error", {"detail": "File chat failed"})
|
|
465
|
+
finally:
|
|
466
|
+
await _clear_interrupt_event(target.state_key)
|
|
467
|
+
|
|
468
|
+
async def _execute_file_chat(
|
|
469
|
+
request: Request,
|
|
470
|
+
repo_root: Path,
|
|
471
|
+
target: _Target,
|
|
472
|
+
message: str,
|
|
473
|
+
*,
|
|
474
|
+
agent: str = "codex",
|
|
475
|
+
model: Optional[str] = None,
|
|
476
|
+
reasoning: Optional[str] = None,
|
|
477
|
+
on_meta: Optional[Callable[[str, str, str], Any]] = None,
|
|
478
|
+
on_usage: Optional[Callable[[Dict[str, Any]], Any]] = None,
|
|
479
|
+
) -> Dict[str, Any]:
|
|
480
|
+
supervisor = getattr(request.app.state, "app_server_supervisor", None)
|
|
481
|
+
threads = getattr(request.app.state, "app_server_threads", None)
|
|
482
|
+
opencode = getattr(request.app.state, "opencode_supervisor", None)
|
|
483
|
+
engine = getattr(request.app.state, "engine", None)
|
|
484
|
+
events = getattr(request.app.state, "app_server_events", None)
|
|
485
|
+
stall_timeout_seconds = None
|
|
486
|
+
try:
|
|
487
|
+
stall_timeout_seconds = (
|
|
488
|
+
engine.config.opencode.session_stall_timeout_seconds
|
|
489
|
+
if engine is not None
|
|
490
|
+
else None
|
|
491
|
+
)
|
|
492
|
+
except Exception:
|
|
493
|
+
stall_timeout_seconds = None
|
|
494
|
+
if supervisor is None and opencode is None:
|
|
495
|
+
raise FileChatError("No agent supervisor available for file chat")
|
|
496
|
+
|
|
497
|
+
before = _read_file(target.path)
|
|
498
|
+
base_hash = _hash_content(before)
|
|
499
|
+
|
|
500
|
+
prompt = _build_file_chat_prompt(target=target, message=message, before=before)
|
|
501
|
+
|
|
502
|
+
interrupt_event = await _get_or_create_interrupt_event(target.state_key)
|
|
503
|
+
if interrupt_event.is_set():
|
|
504
|
+
return {"status": "interrupted", "detail": "File chat interrupted"}
|
|
505
|
+
|
|
506
|
+
try:
|
|
507
|
+
agent_id = validate_agent_id(agent or "")
|
|
508
|
+
except ValueError:
|
|
509
|
+
agent_id = "codex"
|
|
510
|
+
|
|
511
|
+
thread_key = f"file_chat.{target.state_key}"
|
|
512
|
+
await _update_turn_state(target, status="running", agent=agent_id)
|
|
513
|
+
|
|
514
|
+
if agent_id == "opencode":
|
|
515
|
+
if opencode is None:
|
|
516
|
+
return {"status": "error", "detail": "OpenCode supervisor unavailable"}
|
|
517
|
+
result = await _execute_opencode(
|
|
518
|
+
opencode,
|
|
519
|
+
repo_root,
|
|
520
|
+
prompt,
|
|
521
|
+
interrupt_event,
|
|
522
|
+
model=model,
|
|
523
|
+
reasoning=reasoning,
|
|
524
|
+
thread_registry=threads,
|
|
525
|
+
thread_key=thread_key,
|
|
526
|
+
stall_timeout_seconds=stall_timeout_seconds,
|
|
527
|
+
on_meta=on_meta,
|
|
528
|
+
on_usage=on_usage,
|
|
529
|
+
)
|
|
530
|
+
else:
|
|
531
|
+
if supervisor is None:
|
|
532
|
+
return {
|
|
533
|
+
"status": "error",
|
|
534
|
+
"detail": "App-server supervisor unavailable",
|
|
535
|
+
}
|
|
536
|
+
result = await _execute_app_server(
|
|
537
|
+
supervisor,
|
|
538
|
+
repo_root,
|
|
539
|
+
prompt,
|
|
540
|
+
interrupt_event,
|
|
541
|
+
agent_id=agent_id,
|
|
542
|
+
model=model,
|
|
543
|
+
reasoning=reasoning,
|
|
544
|
+
thread_registry=threads,
|
|
545
|
+
thread_key=thread_key,
|
|
546
|
+
on_meta=on_meta,
|
|
547
|
+
events=events,
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
if result.get("status") != "ok":
|
|
551
|
+
return result
|
|
552
|
+
|
|
553
|
+
after = _read_file(target.path)
|
|
554
|
+
|
|
555
|
+
# Restore original content; store draft for apply/discard
|
|
556
|
+
if after != before:
|
|
557
|
+
atomic_write(target.path, before)
|
|
558
|
+
|
|
559
|
+
agent_message = result.get("agent_message", "File updated")
|
|
560
|
+
response_text = result.get("message", agent_message)
|
|
561
|
+
|
|
562
|
+
if after != before:
|
|
563
|
+
patch = _build_patch(target.rel_path, before, after)
|
|
564
|
+
state = _load_state(repo_root)
|
|
565
|
+
drafts = (
|
|
566
|
+
state.get("drafts", {}) if isinstance(state.get("drafts"), dict) else {}
|
|
567
|
+
)
|
|
568
|
+
drafts[target.state_key] = {
|
|
569
|
+
"content": after,
|
|
570
|
+
"patch": patch,
|
|
571
|
+
"agent_message": agent_message,
|
|
572
|
+
"created_at": now_iso(),
|
|
573
|
+
"base_hash": base_hash,
|
|
574
|
+
"target": target.target,
|
|
575
|
+
"rel_path": target.rel_path,
|
|
576
|
+
}
|
|
577
|
+
state["drafts"] = drafts
|
|
578
|
+
_save_state(repo_root, state)
|
|
579
|
+
return {
|
|
580
|
+
"status": "ok",
|
|
581
|
+
"target": target.target,
|
|
582
|
+
"agent": agent_id,
|
|
583
|
+
"agent_message": agent_message,
|
|
584
|
+
"message": response_text,
|
|
585
|
+
"has_draft": True,
|
|
586
|
+
"patch": patch,
|
|
587
|
+
"content": after,
|
|
588
|
+
"base_hash": base_hash,
|
|
589
|
+
"created_at": drafts[target.state_key]["created_at"],
|
|
590
|
+
"thread_id": result.get("thread_id"),
|
|
591
|
+
"turn_id": result.get("turn_id"),
|
|
592
|
+
**(
|
|
593
|
+
{"raw_events": result.get("raw_events")}
|
|
594
|
+
if result.get("raw_events")
|
|
595
|
+
else {}
|
|
596
|
+
),
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
return {
|
|
600
|
+
"status": "ok",
|
|
601
|
+
"target": target.target,
|
|
602
|
+
"agent": agent_id,
|
|
603
|
+
"agent_message": agent_message,
|
|
604
|
+
"message": response_text,
|
|
605
|
+
"has_draft": False,
|
|
606
|
+
"thread_id": result.get("thread_id"),
|
|
607
|
+
"turn_id": result.get("turn_id"),
|
|
608
|
+
**(
|
|
609
|
+
{"raw_events": result.get("raw_events")}
|
|
610
|
+
if result.get("raw_events")
|
|
611
|
+
else {}
|
|
612
|
+
),
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
async def _execute_app_server(
|
|
616
|
+
supervisor: Any,
|
|
617
|
+
repo_root: Path,
|
|
618
|
+
prompt: str,
|
|
619
|
+
interrupt_event: asyncio.Event,
|
|
620
|
+
*,
|
|
621
|
+
model: Optional[str] = None,
|
|
622
|
+
reasoning: Optional[str] = None,
|
|
623
|
+
agent_id: str = "codex",
|
|
624
|
+
thread_registry: Optional[Any] = None,
|
|
625
|
+
thread_key: Optional[str] = None,
|
|
626
|
+
on_meta: Optional[Callable[[str, str, str], Any]] = None,
|
|
627
|
+
events: Optional[Any] = None,
|
|
628
|
+
) -> Dict[str, Any]:
|
|
629
|
+
client = await supervisor.get_client(repo_root)
|
|
630
|
+
|
|
631
|
+
thread_id = None
|
|
632
|
+
if thread_registry is not None and thread_key:
|
|
633
|
+
thread_id = thread_registry.get_thread_id(thread_key)
|
|
634
|
+
if thread_id:
|
|
635
|
+
try:
|
|
636
|
+
await client.thread_resume(thread_id)
|
|
637
|
+
except Exception:
|
|
638
|
+
thread_id = None
|
|
639
|
+
|
|
640
|
+
if not thread_id:
|
|
641
|
+
thread = await client.thread_start(str(repo_root))
|
|
642
|
+
thread_id = thread.get("id")
|
|
643
|
+
if not isinstance(thread_id, str) or not thread_id:
|
|
644
|
+
raise FileChatError("App-server did not return a thread id")
|
|
645
|
+
if thread_registry is not None and thread_key:
|
|
646
|
+
thread_registry.set_thread_id(thread_key, thread_id)
|
|
647
|
+
|
|
648
|
+
turn_kwargs: Dict[str, Any] = {}
|
|
649
|
+
if model:
|
|
650
|
+
turn_kwargs["model"] = model
|
|
651
|
+
if reasoning:
|
|
652
|
+
turn_kwargs["effort"] = reasoning
|
|
653
|
+
|
|
654
|
+
handle = await client.turn_start(
|
|
655
|
+
thread_id,
|
|
656
|
+
prompt,
|
|
657
|
+
approval_policy="on-request",
|
|
658
|
+
sandbox_policy="dangerFullAccess",
|
|
659
|
+
**turn_kwargs,
|
|
660
|
+
)
|
|
661
|
+
if events is not None:
|
|
662
|
+
try:
|
|
663
|
+
await events.register_turn(thread_id, handle.turn_id)
|
|
664
|
+
except Exception:
|
|
665
|
+
logger.debug("file chat register_turn failed", exc_info=True)
|
|
666
|
+
if on_meta is not None:
|
|
667
|
+
try:
|
|
668
|
+
maybe = on_meta(agent_id, thread_id, handle.turn_id)
|
|
669
|
+
if asyncio.iscoroutine(maybe):
|
|
670
|
+
await maybe
|
|
671
|
+
except Exception:
|
|
672
|
+
logger.debug("file chat meta callback failed", exc_info=True)
|
|
673
|
+
|
|
674
|
+
turn_task = asyncio.create_task(handle.wait(timeout=None))
|
|
675
|
+
timeout_task = asyncio.create_task(asyncio.sleep(FILE_CHAT_TIMEOUT_SECONDS))
|
|
676
|
+
interrupt_task = asyncio.create_task(interrupt_event.wait())
|
|
677
|
+
try:
|
|
678
|
+
done, _ = await asyncio.wait(
|
|
679
|
+
{turn_task, timeout_task, interrupt_task},
|
|
680
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
681
|
+
)
|
|
682
|
+
if timeout_task in done:
|
|
683
|
+
turn_task.cancel()
|
|
684
|
+
return {"status": "error", "detail": "File chat timed out"}
|
|
685
|
+
if interrupt_task in done:
|
|
686
|
+
turn_task.cancel()
|
|
687
|
+
return {"status": "interrupted", "detail": "File chat interrupted"}
|
|
688
|
+
turn_result = await turn_task
|
|
689
|
+
finally:
|
|
690
|
+
timeout_task.cancel()
|
|
691
|
+
interrupt_task.cancel()
|
|
692
|
+
|
|
693
|
+
if getattr(turn_result, "errors", None):
|
|
694
|
+
errors = turn_result.errors
|
|
695
|
+
raise FileChatError(errors[-1] if errors else "App-server error")
|
|
696
|
+
|
|
697
|
+
output = "\n".join(getattr(turn_result, "agent_messages", []) or []).strip()
|
|
698
|
+
agent_message = _parse_agent_message(output)
|
|
699
|
+
raw_events = getattr(turn_result, "raw_events", []) or []
|
|
700
|
+
return {
|
|
701
|
+
"status": "ok",
|
|
702
|
+
"agent_message": agent_message,
|
|
703
|
+
"message": output,
|
|
704
|
+
"raw_events": raw_events,
|
|
705
|
+
"thread_id": thread_id,
|
|
706
|
+
"turn_id": getattr(handle, "turn_id", None),
|
|
707
|
+
"agent": agent_id,
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
async def _execute_opencode(
|
|
711
|
+
supervisor: Any,
|
|
712
|
+
repo_root: Path,
|
|
713
|
+
prompt: str,
|
|
714
|
+
interrupt_event: asyncio.Event,
|
|
715
|
+
*,
|
|
716
|
+
model: Optional[str] = None,
|
|
717
|
+
reasoning: Optional[str] = None,
|
|
718
|
+
thread_registry: Optional[Any] = None,
|
|
719
|
+
thread_key: Optional[str] = None,
|
|
720
|
+
stall_timeout_seconds: Optional[float] = None,
|
|
721
|
+
on_meta: Optional[Callable[[str, str, str], Any]] = None,
|
|
722
|
+
on_usage: Optional[Callable[[Dict[str, Any]], Any]] = None,
|
|
723
|
+
) -> Dict[str, Any]:
|
|
724
|
+
from ....agents.opencode.runtime import (
|
|
725
|
+
PERMISSION_ALLOW,
|
|
726
|
+
build_turn_id,
|
|
727
|
+
collect_opencode_output,
|
|
728
|
+
extract_session_id,
|
|
729
|
+
parse_message_response,
|
|
730
|
+
split_model_id,
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
client = await supervisor.get_client(repo_root)
|
|
734
|
+
session_id = None
|
|
735
|
+
if thread_registry is not None and thread_key:
|
|
736
|
+
session_id = thread_registry.get_thread_id(thread_key)
|
|
737
|
+
if not session_id:
|
|
738
|
+
session = await client.create_session(directory=str(repo_root))
|
|
739
|
+
session_id = extract_session_id(session, allow_fallback_id=True)
|
|
740
|
+
if not isinstance(session_id, str) or not session_id:
|
|
741
|
+
raise FileChatError("OpenCode did not return a session id")
|
|
742
|
+
if thread_registry is not None and thread_key:
|
|
743
|
+
thread_registry.set_thread_id(thread_key, session_id)
|
|
744
|
+
|
|
745
|
+
turn_id = build_turn_id(session_id)
|
|
746
|
+
if on_meta is not None:
|
|
747
|
+
try:
|
|
748
|
+
maybe = on_meta("opencode", session_id, turn_id)
|
|
749
|
+
if asyncio.iscoroutine(maybe):
|
|
750
|
+
await maybe
|
|
751
|
+
except Exception:
|
|
752
|
+
logger.debug("file chat opencode meta failed", exc_info=True)
|
|
753
|
+
|
|
754
|
+
model_payload = split_model_id(model)
|
|
755
|
+
await supervisor.mark_turn_started(repo_root)
|
|
756
|
+
|
|
757
|
+
usage_parts: list[Dict[str, Any]] = []
|
|
758
|
+
|
|
759
|
+
async def _part_handler(
|
|
760
|
+
part_type: str, part: Any, turn_id_arg: Optional[str] | None
|
|
761
|
+
) -> None:
|
|
762
|
+
if part_type == "usage" and on_usage is not None:
|
|
763
|
+
usage_parts.append(part)
|
|
764
|
+
try:
|
|
765
|
+
maybe = on_usage(part)
|
|
766
|
+
if asyncio.iscoroutine(maybe):
|
|
767
|
+
await maybe
|
|
768
|
+
except Exception:
|
|
769
|
+
logger.debug("file chat usage handler failed", exc_info=True)
|
|
770
|
+
|
|
771
|
+
ready_event = asyncio.Event()
|
|
772
|
+
output_task = asyncio.create_task(
|
|
773
|
+
collect_opencode_output(
|
|
774
|
+
client,
|
|
775
|
+
session_id=session_id,
|
|
776
|
+
workspace_path=str(repo_root),
|
|
777
|
+
model_payload=model_payload,
|
|
778
|
+
permission_policy=PERMISSION_ALLOW,
|
|
779
|
+
question_policy="auto_first_option",
|
|
780
|
+
should_stop=interrupt_event.is_set,
|
|
781
|
+
ready_event=ready_event,
|
|
782
|
+
part_handler=_part_handler,
|
|
783
|
+
stall_timeout_seconds=stall_timeout_seconds,
|
|
784
|
+
)
|
|
785
|
+
)
|
|
786
|
+
with contextlib.suppress(asyncio.TimeoutError):
|
|
787
|
+
await asyncio.wait_for(ready_event.wait(), timeout=2.0)
|
|
788
|
+
|
|
789
|
+
prompt_task = asyncio.create_task(
|
|
790
|
+
client.prompt_async(
|
|
791
|
+
session_id,
|
|
792
|
+
message=prompt,
|
|
793
|
+
model=model_payload,
|
|
794
|
+
variant=reasoning,
|
|
795
|
+
)
|
|
796
|
+
)
|
|
797
|
+
timeout_task = asyncio.create_task(asyncio.sleep(FILE_CHAT_TIMEOUT_SECONDS))
|
|
798
|
+
interrupt_task = asyncio.create_task(interrupt_event.wait())
|
|
799
|
+
try:
|
|
800
|
+
prompt_response = None
|
|
801
|
+
try:
|
|
802
|
+
prompt_response = await prompt_task
|
|
803
|
+
except Exception as exc:
|
|
804
|
+
interrupt_event.set()
|
|
805
|
+
output_task.cancel()
|
|
806
|
+
raise FileChatError(f"OpenCode prompt failed: {exc}") from exc
|
|
807
|
+
|
|
808
|
+
done, _ = await asyncio.wait(
|
|
809
|
+
{output_task, timeout_task, interrupt_task},
|
|
810
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
811
|
+
)
|
|
812
|
+
if timeout_task in done:
|
|
813
|
+
output_task.cancel()
|
|
814
|
+
return {"status": "error", "detail": "File chat timed out"}
|
|
815
|
+
if interrupt_task in done:
|
|
816
|
+
output_task.cancel()
|
|
817
|
+
return {"status": "interrupted", "detail": "File chat interrupted"}
|
|
818
|
+
output_result = await output_task
|
|
819
|
+
if (not output_result.text) and prompt_response is not None:
|
|
820
|
+
fallback = parse_message_response(prompt_response)
|
|
821
|
+
if fallback.text:
|
|
822
|
+
output_result = type(output_result)(
|
|
823
|
+
text=fallback.text, error=fallback.error
|
|
824
|
+
)
|
|
825
|
+
finally:
|
|
826
|
+
timeout_task.cancel()
|
|
827
|
+
interrupt_task.cancel()
|
|
828
|
+
await supervisor.mark_turn_finished(repo_root)
|
|
829
|
+
|
|
830
|
+
if output_result.error:
|
|
831
|
+
raise FileChatError(output_result.error)
|
|
832
|
+
agent_message = _parse_agent_message(output_result.text)
|
|
833
|
+
result = {
|
|
834
|
+
"status": "ok",
|
|
835
|
+
"agent_message": agent_message,
|
|
836
|
+
"message": output_result.text,
|
|
837
|
+
"thread_id": session_id,
|
|
838
|
+
"turn_id": turn_id,
|
|
839
|
+
"agent": "opencode",
|
|
840
|
+
}
|
|
841
|
+
if usage_parts:
|
|
842
|
+
result["usage_parts"] = usage_parts
|
|
843
|
+
return result
|
|
844
|
+
|
|
845
|
+
def _parse_agent_message(output: str) -> str:
|
|
846
|
+
text = (output or "").strip()
|
|
847
|
+
if not text:
|
|
848
|
+
return "File updated via chat."
|
|
849
|
+
for line in text.splitlines():
|
|
850
|
+
if line.lower().startswith("agent:"):
|
|
851
|
+
return line[len("agent:") :].strip() or "File updated via chat."
|
|
852
|
+
first_line = text.splitlines()[0].strip()
|
|
853
|
+
return (first_line[:97] + "...") if len(first_line) > 100 else first_line
|
|
854
|
+
|
|
855
|
+
@router.get("/file-chat/pending")
|
|
856
|
+
async def pending_file_patch(request: Request, target: str):
|
|
857
|
+
repo_root = _resolve_repo_root(request)
|
|
858
|
+
resolved = _parse_target(repo_root, target)
|
|
859
|
+
state = _load_state(repo_root)
|
|
860
|
+
drafts = (
|
|
861
|
+
state.get("drafts", {}) if isinstance(state.get("drafts"), dict) else {}
|
|
862
|
+
)
|
|
863
|
+
draft = drafts.get(resolved.state_key)
|
|
864
|
+
if not draft:
|
|
865
|
+
raise HTTPException(status_code=404, detail="No pending patch")
|
|
866
|
+
current_content = _read_file(resolved.path)
|
|
867
|
+
current_hash = _hash_content(current_content)
|
|
868
|
+
return {
|
|
869
|
+
"status": "ok",
|
|
870
|
+
"target": resolved.target,
|
|
871
|
+
"patch": draft.get("patch", ""),
|
|
872
|
+
"content": draft.get("content", ""),
|
|
873
|
+
"agent_message": draft.get("agent_message", ""),
|
|
874
|
+
"created_at": draft.get("created_at", ""),
|
|
875
|
+
"base_hash": draft.get("base_hash", ""),
|
|
876
|
+
"current_hash": current_hash,
|
|
877
|
+
"is_stale": draft.get("base_hash") not in (None, "")
|
|
878
|
+
and draft.get("base_hash") != current_hash,
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
@router.post("/file-chat/apply")
|
|
882
|
+
async def apply_file_patch(request: Request):
|
|
883
|
+
body = await request.json()
|
|
884
|
+
repo_root = _resolve_repo_root(request)
|
|
885
|
+
resolved = _parse_target(repo_root, str(body.get("target") or ""))
|
|
886
|
+
force = bool(body.get("force", False))
|
|
887
|
+
state = _load_state(repo_root)
|
|
888
|
+
drafts = (
|
|
889
|
+
state.get("drafts", {}) if isinstance(state.get("drafts"), dict) else {}
|
|
890
|
+
)
|
|
891
|
+
draft = drafts.get(resolved.state_key)
|
|
892
|
+
if not draft:
|
|
893
|
+
raise HTTPException(status_code=404, detail="No pending patch")
|
|
894
|
+
|
|
895
|
+
current = _read_file(resolved.path)
|
|
896
|
+
if (
|
|
897
|
+
not force
|
|
898
|
+
and draft.get("base_hash")
|
|
899
|
+
and _hash_content(current) != draft["base_hash"]
|
|
900
|
+
):
|
|
901
|
+
raise HTTPException(
|
|
902
|
+
status_code=409,
|
|
903
|
+
detail="File changed since draft created; reload before applying.",
|
|
904
|
+
)
|
|
905
|
+
|
|
906
|
+
content = draft.get("content", "")
|
|
907
|
+
resolved.path.parent.mkdir(parents=True, exist_ok=True)
|
|
908
|
+
atomic_write(resolved.path, content)
|
|
909
|
+
|
|
910
|
+
drafts.pop(resolved.state_key, None)
|
|
911
|
+
state["drafts"] = drafts
|
|
912
|
+
_save_state(repo_root, state)
|
|
913
|
+
|
|
914
|
+
return {
|
|
915
|
+
"status": "ok",
|
|
916
|
+
"target": resolved.target,
|
|
917
|
+
"content": _read_file(resolved.path),
|
|
918
|
+
"agent_message": draft.get("agent_message", "Draft applied"),
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
@router.post("/file-chat/discard")
|
|
922
|
+
async def discard_file_patch(request: Request):
|
|
923
|
+
body = await request.json()
|
|
924
|
+
repo_root = _resolve_repo_root(request)
|
|
925
|
+
resolved = _parse_target(repo_root, str(body.get("target") or ""))
|
|
926
|
+
state = _load_state(repo_root)
|
|
927
|
+
drafts = (
|
|
928
|
+
state.get("drafts", {}) if isinstance(state.get("drafts"), dict) else {}
|
|
929
|
+
)
|
|
930
|
+
drafts.pop(resolved.state_key, None)
|
|
931
|
+
state["drafts"] = drafts
|
|
932
|
+
_save_state(repo_root, state)
|
|
933
|
+
return {
|
|
934
|
+
"status": "ok",
|
|
935
|
+
"target": resolved.target,
|
|
936
|
+
"content": _read_file(resolved.path),
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
@router.get("/file-chat/turns/{turn_id}/events")
|
|
940
|
+
async def stream_file_chat_turn_events(
|
|
941
|
+
turn_id: str, request: Request, thread_id: str, agent: str = "codex"
|
|
942
|
+
):
|
|
943
|
+
agent_id = (agent or "").strip().lower()
|
|
944
|
+
if agent_id == "codex":
|
|
945
|
+
events = getattr(request.app.state, "app_server_events", None)
|
|
946
|
+
if events is None:
|
|
947
|
+
raise HTTPException(status_code=404, detail="Events unavailable")
|
|
948
|
+
if not thread_id:
|
|
949
|
+
raise HTTPException(status_code=400, detail="thread_id is required")
|
|
950
|
+
return StreamingResponse(
|
|
951
|
+
events.stream(thread_id, turn_id),
|
|
952
|
+
media_type="text/event-stream",
|
|
953
|
+
headers=SSE_HEADERS,
|
|
954
|
+
)
|
|
955
|
+
if agent_id == "opencode":
|
|
956
|
+
supervisor = getattr(request.app.state, "opencode_supervisor", None)
|
|
957
|
+
if supervisor is None:
|
|
958
|
+
raise HTTPException(status_code=404, detail="OpenCode unavailable")
|
|
959
|
+
from ....agents.opencode.harness import OpenCodeHarness
|
|
960
|
+
|
|
961
|
+
harness = OpenCodeHarness(supervisor)
|
|
962
|
+
repo_root = _resolve_repo_root(request)
|
|
963
|
+
return StreamingResponse(
|
|
964
|
+
harness.stream_events(repo_root, thread_id, turn_id),
|
|
965
|
+
media_type="text/event-stream",
|
|
966
|
+
headers=SSE_HEADERS,
|
|
967
|
+
)
|
|
968
|
+
raise HTTPException(status_code=404, detail="Unknown agent")
|
|
969
|
+
|
|
970
|
+
@router.post("/file-chat/interrupt")
|
|
971
|
+
async def interrupt_file_chat(request: Request):
|
|
972
|
+
body = await request.json()
|
|
973
|
+
repo_root = _resolve_repo_root(request)
|
|
974
|
+
resolved = _parse_target(repo_root, str(body.get("target") or ""))
|
|
975
|
+
async with _chat_lock:
|
|
976
|
+
ev = _active_chats.get(resolved.state_key)
|
|
977
|
+
if ev is None:
|
|
978
|
+
return {"status": "ok", "detail": "No active chat to interrupt"}
|
|
979
|
+
ev.set()
|
|
980
|
+
return {"status": "interrupted", "detail": "File chat interrupted"}
|
|
981
|
+
|
|
982
|
+
# Legacy ticket endpoints (thin wrappers) to keep older UIs working.
|
|
983
|
+
|
|
984
|
+
@router.post("/tickets/{index}/chat")
|
|
985
|
+
async def chat_ticket(index: int, request: Request):
|
|
986
|
+
body = await request.json()
|
|
987
|
+
message = (body.get("message") or "").strip()
|
|
988
|
+
stream = bool(body.get("stream", False))
|
|
989
|
+
agent = body.get("agent", "codex")
|
|
990
|
+
model = body.get("model")
|
|
991
|
+
reasoning = body.get("reasoning")
|
|
992
|
+
client_turn_id = (body.get("client_turn_id") or "").strip() or None
|
|
993
|
+
|
|
994
|
+
if not message:
|
|
995
|
+
raise HTTPException(status_code=400, detail="message is required")
|
|
996
|
+
|
|
997
|
+
repo_root = _resolve_repo_root(request)
|
|
998
|
+
target = _parse_target(repo_root, f"ticket:{int(index)}")
|
|
999
|
+
|
|
1000
|
+
async with _chat_lock:
|
|
1001
|
+
existing = _active_chats.get(target.state_key)
|
|
1002
|
+
if existing is not None and not existing.is_set():
|
|
1003
|
+
raise HTTPException(
|
|
1004
|
+
status_code=409, detail="Ticket chat already running"
|
|
1005
|
+
)
|
|
1006
|
+
_active_chats[target.state_key] = asyncio.Event()
|
|
1007
|
+
await _begin_turn_state(target, client_turn_id)
|
|
1008
|
+
|
|
1009
|
+
if stream:
|
|
1010
|
+
return StreamingResponse(
|
|
1011
|
+
_stream_file_chat(
|
|
1012
|
+
request,
|
|
1013
|
+
repo_root,
|
|
1014
|
+
target,
|
|
1015
|
+
message,
|
|
1016
|
+
agent=agent,
|
|
1017
|
+
model=model,
|
|
1018
|
+
reasoning=reasoning,
|
|
1019
|
+
client_turn_id=client_turn_id,
|
|
1020
|
+
),
|
|
1021
|
+
media_type="text/event-stream",
|
|
1022
|
+
headers=SSE_HEADERS,
|
|
1023
|
+
)
|
|
1024
|
+
|
|
1025
|
+
try:
|
|
1026
|
+
result = await _execute_file_chat(
|
|
1027
|
+
request,
|
|
1028
|
+
repo_root,
|
|
1029
|
+
target,
|
|
1030
|
+
message,
|
|
1031
|
+
agent=agent,
|
|
1032
|
+
model=model,
|
|
1033
|
+
reasoning=reasoning,
|
|
1034
|
+
)
|
|
1035
|
+
result = dict(result or {})
|
|
1036
|
+
result["client_turn_id"] = client_turn_id or ""
|
|
1037
|
+
await _finalize_turn_state(target, result)
|
|
1038
|
+
return result
|
|
1039
|
+
finally:
|
|
1040
|
+
await _clear_interrupt_event(target.state_key)
|
|
1041
|
+
|
|
1042
|
+
@router.get("/tickets/{index}/chat/pending")
|
|
1043
|
+
async def pending_ticket_patch(index: int, request: Request):
|
|
1044
|
+
return await pending_file_patch(request, target=f"ticket:{int(index)}")
|
|
1045
|
+
|
|
1046
|
+
@router.post("/tickets/{index}/chat/apply")
|
|
1047
|
+
async def apply_ticket_patch(index: int, request: Request):
|
|
1048
|
+
try:
|
|
1049
|
+
body = await request.json()
|
|
1050
|
+
except Exception:
|
|
1051
|
+
body = {}
|
|
1052
|
+
repo_root = _resolve_repo_root(request)
|
|
1053
|
+
target = _parse_target(repo_root, f"ticket:{int(index)}")
|
|
1054
|
+
force = bool(body.get("force", False)) if isinstance(body, dict) else False
|
|
1055
|
+
state = _load_state(repo_root)
|
|
1056
|
+
drafts = (
|
|
1057
|
+
state.get("drafts", {}) if isinstance(state.get("drafts"), dict) else {}
|
|
1058
|
+
)
|
|
1059
|
+
draft = drafts.get(target.state_key)
|
|
1060
|
+
if not draft:
|
|
1061
|
+
raise HTTPException(status_code=404, detail="No pending patch")
|
|
1062
|
+
|
|
1063
|
+
current = _read_file(target.path)
|
|
1064
|
+
if (
|
|
1065
|
+
not force
|
|
1066
|
+
and draft.get("base_hash")
|
|
1067
|
+
and _hash_content(current) != draft["base_hash"]
|
|
1068
|
+
):
|
|
1069
|
+
raise HTTPException(
|
|
1070
|
+
status_code=409,
|
|
1071
|
+
detail="Ticket changed since draft created; reload before applying.",
|
|
1072
|
+
)
|
|
1073
|
+
|
|
1074
|
+
content = draft.get("content", "")
|
|
1075
|
+
target.path.parent.mkdir(parents=True, exist_ok=True)
|
|
1076
|
+
atomic_write(target.path, content)
|
|
1077
|
+
|
|
1078
|
+
drafts.pop(target.state_key, None)
|
|
1079
|
+
state["drafts"] = drafts
|
|
1080
|
+
_save_state(repo_root, state)
|
|
1081
|
+
|
|
1082
|
+
return {
|
|
1083
|
+
"status": "ok",
|
|
1084
|
+
"index": int(index),
|
|
1085
|
+
"content": _read_file(target.path),
|
|
1086
|
+
"agent_message": draft.get("agent_message", "Draft applied"),
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
@router.post("/tickets/{index}/chat/discard")
|
|
1090
|
+
async def discard_ticket_patch(index: int, request: Request):
|
|
1091
|
+
repo_root = _resolve_repo_root(request)
|
|
1092
|
+
target = _parse_target(repo_root, f"ticket:{int(index)}")
|
|
1093
|
+
state = _load_state(repo_root)
|
|
1094
|
+
drafts = (
|
|
1095
|
+
state.get("drafts", {}) if isinstance(state.get("drafts"), dict) else {}
|
|
1096
|
+
)
|
|
1097
|
+
drafts.pop(target.state_key, None)
|
|
1098
|
+
state["drafts"] = drafts
|
|
1099
|
+
_save_state(repo_root, state)
|
|
1100
|
+
return {
|
|
1101
|
+
"status": "ok",
|
|
1102
|
+
"index": int(index),
|
|
1103
|
+
"content": _read_file(target.path),
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
@router.post("/tickets/{index}/chat/interrupt")
|
|
1107
|
+
async def interrupt_ticket_chat(index: int, request: Request):
|
|
1108
|
+
repo_root = _resolve_repo_root(request)
|
|
1109
|
+
target = _parse_target(repo_root, f"ticket:{int(index)}")
|
|
1110
|
+
async with _chat_lock:
|
|
1111
|
+
ev = _active_chats.get(target.state_key)
|
|
1112
|
+
if ev is None:
|
|
1113
|
+
return {"status": "ok", "detail": "No active chat to interrupt"}
|
|
1114
|
+
ev.set()
|
|
1115
|
+
return {"status": "interrupted", "detail": "Ticket chat interrupted"}
|
|
1116
|
+
|
|
1117
|
+
return router
|