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
|
@@ -0,0 +1,2043 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Optional, Sequence
|
|
7
|
+
|
|
8
|
+
from .....agents.opencode.runtime import extract_session_id
|
|
9
|
+
from .....core.logging_utils import log_event
|
|
10
|
+
from .....core.state import now_iso
|
|
11
|
+
from .....core.utils import canonicalize_path, resolve_opencode_binary
|
|
12
|
+
from .....manifest import load_manifest
|
|
13
|
+
from ....app_server.client import (
|
|
14
|
+
CodexAppServerClient,
|
|
15
|
+
)
|
|
16
|
+
from ...adapter import (
|
|
17
|
+
TelegramCallbackQuery,
|
|
18
|
+
TelegramMessage,
|
|
19
|
+
)
|
|
20
|
+
from ...config import AppServerUnavailableError
|
|
21
|
+
from ...constants import (
|
|
22
|
+
AGENT_PICKER_PROMPT,
|
|
23
|
+
BIND_PICKER_PROMPT,
|
|
24
|
+
DEFAULT_AGENT,
|
|
25
|
+
DEFAULT_AGENT_MODELS,
|
|
26
|
+
DEFAULT_PAGE_SIZE,
|
|
27
|
+
MAX_TOPIC_THREAD_HISTORY,
|
|
28
|
+
RESUME_MISSING_IDS_LOG_LIMIT,
|
|
29
|
+
RESUME_PICKER_PROMPT,
|
|
30
|
+
RESUME_REFRESH_LIMIT,
|
|
31
|
+
THREAD_LIST_MAX_PAGES,
|
|
32
|
+
VALID_AGENT_VALUES,
|
|
33
|
+
)
|
|
34
|
+
from ...helpers import (
|
|
35
|
+
_approval_age_seconds,
|
|
36
|
+
_coerce_thread_list,
|
|
37
|
+
_extract_first_user_preview,
|
|
38
|
+
_extract_thread_id,
|
|
39
|
+
_extract_thread_info,
|
|
40
|
+
_extract_thread_list_cursor,
|
|
41
|
+
_extract_thread_preview_parts,
|
|
42
|
+
_format_missing_thread_label,
|
|
43
|
+
_format_rate_limits,
|
|
44
|
+
_format_resume_summary,
|
|
45
|
+
_format_sandbox_policy,
|
|
46
|
+
_format_thread_preview,
|
|
47
|
+
_format_token_usage,
|
|
48
|
+
_local_workspace_threads,
|
|
49
|
+
_page_slice,
|
|
50
|
+
_partition_threads,
|
|
51
|
+
_paths_compatible,
|
|
52
|
+
_resume_thread_list_limit,
|
|
53
|
+
_set_thread_summary,
|
|
54
|
+
_split_topic_key,
|
|
55
|
+
_thread_summary_preview,
|
|
56
|
+
_with_conversation_id,
|
|
57
|
+
)
|
|
58
|
+
from ...state import APPROVAL_MODE_YOLO, normalize_agent
|
|
59
|
+
from ...types import SelectionState
|
|
60
|
+
from .shared import SharedHelpers
|
|
61
|
+
|
|
62
|
+
if TYPE_CHECKING:
|
|
63
|
+
from ...state import TelegramTopicRecord
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _extract_opencode_session_path(payload: Any) -> Optional[str]:
|
|
67
|
+
if not isinstance(payload, dict):
|
|
68
|
+
return None
|
|
69
|
+
for key in ("directory", "path", "workspace_path", "workspacePath"):
|
|
70
|
+
value = payload.get(key)
|
|
71
|
+
if isinstance(value, str) and value:
|
|
72
|
+
return value
|
|
73
|
+
properties = payload.get("properties")
|
|
74
|
+
if isinstance(properties, dict):
|
|
75
|
+
for key in ("directory", "path", "workspace_path", "workspacePath"):
|
|
76
|
+
value = properties.get(key)
|
|
77
|
+
if isinstance(value, str) and value:
|
|
78
|
+
return value
|
|
79
|
+
session = payload.get("session")
|
|
80
|
+
if isinstance(session, dict):
|
|
81
|
+
return _extract_opencode_session_path(session)
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class ResumeCommandArgs:
|
|
87
|
+
"""Parsed /resume command options."""
|
|
88
|
+
|
|
89
|
+
trimmed: str
|
|
90
|
+
remaining: list[str]
|
|
91
|
+
show_unscoped: bool
|
|
92
|
+
refresh: bool
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class ResumeThreadData:
|
|
97
|
+
"""Thread listing details used to render the resume picker."""
|
|
98
|
+
|
|
99
|
+
candidates: list[dict[str, Any]]
|
|
100
|
+
entries_by_id: dict[str, dict[str, Any]]
|
|
101
|
+
local_thread_ids: list[str]
|
|
102
|
+
local_previews: dict[str, str]
|
|
103
|
+
local_thread_topics: dict[str, set[str]]
|
|
104
|
+
list_failed: bool
|
|
105
|
+
threads: list[dict[str, Any]]
|
|
106
|
+
unscoped_entries: list[dict[str, Any]]
|
|
107
|
+
saw_path: bool
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class WorkspaceCommands(SharedHelpers):
|
|
111
|
+
async def _apply_agent_change(
|
|
112
|
+
self,
|
|
113
|
+
chat_id: int,
|
|
114
|
+
thread_id: Optional[int],
|
|
115
|
+
desired: str,
|
|
116
|
+
) -> str:
|
|
117
|
+
def apply(record: "TelegramTopicRecord") -> None:
|
|
118
|
+
record.agent = desired
|
|
119
|
+
record.active_thread_id = None
|
|
120
|
+
record.thread_ids.clear()
|
|
121
|
+
record.thread_summaries.clear()
|
|
122
|
+
record.pending_compact_seed = None
|
|
123
|
+
record.pending_compact_seed_thread_id = None
|
|
124
|
+
if not self._agent_supports_effort(desired):
|
|
125
|
+
record.effort = None
|
|
126
|
+
record.model = DEFAULT_AGENT_MODELS.get(desired)
|
|
127
|
+
|
|
128
|
+
await self._router.update_topic(chat_id, thread_id, apply)
|
|
129
|
+
if not self._agent_supports_resume(desired):
|
|
130
|
+
return " (resume not supported)"
|
|
131
|
+
return ""
|
|
132
|
+
|
|
133
|
+
async def _handle_agent(
|
|
134
|
+
self, message: TelegramMessage, args: str, _runtime: Any
|
|
135
|
+
) -> None:
|
|
136
|
+
record = await self._router.ensure_topic(message.chat_id, message.thread_id)
|
|
137
|
+
current = self._effective_agent(record)
|
|
138
|
+
key = await self._resolve_topic_key(message.chat_id, message.thread_id)
|
|
139
|
+
self._agent_options.pop(key, None)
|
|
140
|
+
argv = self._parse_command_args(args)
|
|
141
|
+
if not argv:
|
|
142
|
+
availability = "available"
|
|
143
|
+
if not self._opencode_available():
|
|
144
|
+
availability = "missing binary"
|
|
145
|
+
items = []
|
|
146
|
+
for agent in ("codex", "opencode"):
|
|
147
|
+
if agent not in VALID_AGENT_VALUES:
|
|
148
|
+
continue
|
|
149
|
+
label = agent
|
|
150
|
+
if agent == current:
|
|
151
|
+
label = f"{label} (current)"
|
|
152
|
+
if agent == "opencode" and availability != "available":
|
|
153
|
+
label = f"{label} ({availability})"
|
|
154
|
+
items.append((agent, label))
|
|
155
|
+
state = SelectionState(items=items)
|
|
156
|
+
keyboard = self._build_agent_keyboard(state)
|
|
157
|
+
self._agent_options[key] = state
|
|
158
|
+
self._touch_cache_timestamp("agent_options", key)
|
|
159
|
+
await self._send_message(
|
|
160
|
+
message.chat_id,
|
|
161
|
+
self._selection_prompt(AGENT_PICKER_PROMPT, state),
|
|
162
|
+
thread_id=message.thread_id,
|
|
163
|
+
reply_to=message.message_id,
|
|
164
|
+
reply_markup=keyboard,
|
|
165
|
+
)
|
|
166
|
+
return
|
|
167
|
+
desired = normalize_agent(argv[0])
|
|
168
|
+
try:
|
|
169
|
+
client = await self._client_for_workspace(record.workspace_path)
|
|
170
|
+
except AppServerUnavailableError as exc:
|
|
171
|
+
log_event(
|
|
172
|
+
self._logger,
|
|
173
|
+
logging.WARNING,
|
|
174
|
+
"telegram.app_server.unavailable",
|
|
175
|
+
chat_id=message.chat_id,
|
|
176
|
+
thread_id=message.thread_id,
|
|
177
|
+
exc=exc,
|
|
178
|
+
)
|
|
179
|
+
await self._send_message(
|
|
180
|
+
message.chat_id,
|
|
181
|
+
"App server unavailable; try again or check logs.",
|
|
182
|
+
thread_id=message.thread_id,
|
|
183
|
+
reply_to=message.message_id,
|
|
184
|
+
)
|
|
185
|
+
return
|
|
186
|
+
if client is None:
|
|
187
|
+
await self._send_message(
|
|
188
|
+
message.chat_id,
|
|
189
|
+
"Topic not bound. Use /bind <repo_id> or /bind <path>.",
|
|
190
|
+
thread_id=message.thread_id,
|
|
191
|
+
reply_to=message.message_id,
|
|
192
|
+
)
|
|
193
|
+
return
|
|
194
|
+
if desired == "opencode" and not self._opencode_available():
|
|
195
|
+
await self._send_message(
|
|
196
|
+
message.chat_id,
|
|
197
|
+
"OpenCode binary not found. Install opencode or switch to /agent codex.",
|
|
198
|
+
thread_id=message.thread_id,
|
|
199
|
+
reply_to=message.message_id,
|
|
200
|
+
)
|
|
201
|
+
return
|
|
202
|
+
if desired == current:
|
|
203
|
+
await self._send_message(
|
|
204
|
+
message.chat_id,
|
|
205
|
+
f"Agent already set to {current}.",
|
|
206
|
+
thread_id=message.thread_id,
|
|
207
|
+
reply_to=message.message_id,
|
|
208
|
+
)
|
|
209
|
+
return
|
|
210
|
+
note = await self._apply_agent_change(
|
|
211
|
+
message.chat_id, message.thread_id, desired
|
|
212
|
+
)
|
|
213
|
+
await self._send_message(
|
|
214
|
+
message.chat_id,
|
|
215
|
+
f"Agent set to {desired}{note}.",
|
|
216
|
+
thread_id=message.thread_id,
|
|
217
|
+
reply_to=message.message_id,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
def _effective_policies(
|
|
221
|
+
self, record: "TelegramTopicRecord"
|
|
222
|
+
) -> tuple[Optional[str], Optional[Any]]:
|
|
223
|
+
approval_policy, sandbox_policy = self._config.defaults.policies_for_mode(
|
|
224
|
+
record.approval_mode
|
|
225
|
+
)
|
|
226
|
+
if record.approval_policy is not None:
|
|
227
|
+
approval_policy = record.approval_policy
|
|
228
|
+
if record.sandbox_policy is not None:
|
|
229
|
+
sandbox_policy = record.sandbox_policy
|
|
230
|
+
return approval_policy, sandbox_policy
|
|
231
|
+
|
|
232
|
+
def _effective_agent(self, record: Optional["TelegramTopicRecord"]) -> str:
|
|
233
|
+
if record and record.agent in VALID_AGENT_VALUES:
|
|
234
|
+
return record.agent
|
|
235
|
+
return DEFAULT_AGENT
|
|
236
|
+
|
|
237
|
+
def _agent_supports_effort(self, agent: str) -> bool:
|
|
238
|
+
return agent == "codex"
|
|
239
|
+
|
|
240
|
+
def _agent_supports_resume(self, agent: str) -> bool:
|
|
241
|
+
return agent in ("codex", "opencode")
|
|
242
|
+
|
|
243
|
+
def _agent_rate_limit_source(self, agent: str) -> Optional[str]:
|
|
244
|
+
if agent == "codex":
|
|
245
|
+
return "app_server"
|
|
246
|
+
return None
|
|
247
|
+
|
|
248
|
+
def _opencode_available(self) -> bool:
|
|
249
|
+
opencode_command = self._config.opencode_command
|
|
250
|
+
if opencode_command and resolve_opencode_binary(opencode_command[0]):
|
|
251
|
+
return True
|
|
252
|
+
binary = self._config.agent_binaries.get("opencode")
|
|
253
|
+
if not binary:
|
|
254
|
+
return False
|
|
255
|
+
return resolve_opencode_binary(binary) is not None
|
|
256
|
+
|
|
257
|
+
async def _fetch_model_list(
|
|
258
|
+
self,
|
|
259
|
+
record: Optional["TelegramTopicRecord"],
|
|
260
|
+
*,
|
|
261
|
+
agent: str,
|
|
262
|
+
client: CodexAppServerClient,
|
|
263
|
+
list_params: dict[str, Any],
|
|
264
|
+
) -> Any:
|
|
265
|
+
if agent == "opencode":
|
|
266
|
+
supervisor = getattr(self, "_opencode_supervisor", None)
|
|
267
|
+
if supervisor is None:
|
|
268
|
+
from .....agents.opencode.supervisor import OpenCodeSupervisorError
|
|
269
|
+
|
|
270
|
+
raise OpenCodeSupervisorError("OpenCode backend is not configured")
|
|
271
|
+
workspace_root = self._canonical_workspace_root(
|
|
272
|
+
record.workspace_path if record else None
|
|
273
|
+
)
|
|
274
|
+
if workspace_root is None:
|
|
275
|
+
from .....agents.opencode.supervisor import OpenCodeSupervisorError
|
|
276
|
+
|
|
277
|
+
raise OpenCodeSupervisorError("OpenCode workspace is unavailable")
|
|
278
|
+
from .....agents.opencode.harness import OpenCodeHarness
|
|
279
|
+
|
|
280
|
+
harness = OpenCodeHarness(supervisor)
|
|
281
|
+
catalog = await harness.model_catalog(workspace_root)
|
|
282
|
+
return [
|
|
283
|
+
{
|
|
284
|
+
"id": model.id,
|
|
285
|
+
"displayName": model.display_name,
|
|
286
|
+
}
|
|
287
|
+
for model in catalog.models
|
|
288
|
+
]
|
|
289
|
+
return await client.request("model/list", list_params)
|
|
290
|
+
|
|
291
|
+
async def _verify_active_thread(
|
|
292
|
+
self, message: TelegramMessage, record: "TelegramTopicRecord"
|
|
293
|
+
) -> Optional["TelegramTopicRecord"]:
|
|
294
|
+
agent = self._effective_agent(record)
|
|
295
|
+
if agent == "opencode":
|
|
296
|
+
if not record.active_thread_id:
|
|
297
|
+
return record
|
|
298
|
+
supervisor = getattr(self, "_opencode_supervisor", None)
|
|
299
|
+
if supervisor is None:
|
|
300
|
+
await self._send_message(
|
|
301
|
+
message.chat_id,
|
|
302
|
+
"OpenCode backend unavailable; install opencode or switch to /agent codex.",
|
|
303
|
+
thread_id=message.thread_id,
|
|
304
|
+
reply_to=message.message_id,
|
|
305
|
+
)
|
|
306
|
+
return await self._router.set_active_thread(
|
|
307
|
+
message.chat_id, message.thread_id, None
|
|
308
|
+
)
|
|
309
|
+
workspace_root = self._canonical_workspace_root(record.workspace_path)
|
|
310
|
+
if workspace_root is None:
|
|
311
|
+
return record
|
|
312
|
+
try:
|
|
313
|
+
client = await supervisor.get_client(workspace_root)
|
|
314
|
+
await client.get_session(record.active_thread_id)
|
|
315
|
+
return record
|
|
316
|
+
except Exception:
|
|
317
|
+
return await self._router.set_active_thread(
|
|
318
|
+
message.chat_id, message.thread_id, None
|
|
319
|
+
)
|
|
320
|
+
if not self._agent_supports_resume(agent):
|
|
321
|
+
return record
|
|
322
|
+
thread_id = record.active_thread_id
|
|
323
|
+
if not thread_id:
|
|
324
|
+
return record
|
|
325
|
+
try:
|
|
326
|
+
client = await self._client_for_workspace(record.workspace_path)
|
|
327
|
+
except AppServerUnavailableError as exc:
|
|
328
|
+
log_event(
|
|
329
|
+
self._logger,
|
|
330
|
+
logging.WARNING,
|
|
331
|
+
"telegram.app_server.unavailable",
|
|
332
|
+
chat_id=message.chat_id,
|
|
333
|
+
thread_id=message.thread_id,
|
|
334
|
+
exc=exc,
|
|
335
|
+
)
|
|
336
|
+
await self._send_message(
|
|
337
|
+
message.chat_id,
|
|
338
|
+
"App server unavailable; try again or check logs.",
|
|
339
|
+
thread_id=message.thread_id,
|
|
340
|
+
reply_to=message.message_id,
|
|
341
|
+
)
|
|
342
|
+
return None
|
|
343
|
+
if client is None:
|
|
344
|
+
await self._send_message(
|
|
345
|
+
message.chat_id,
|
|
346
|
+
"Topic not bound. Use /bind <repo_id> or /bind <path>.",
|
|
347
|
+
thread_id=message.thread_id,
|
|
348
|
+
reply_to=message.message_id,
|
|
349
|
+
)
|
|
350
|
+
return None
|
|
351
|
+
try:
|
|
352
|
+
result = await client.thread_resume(thread_id)
|
|
353
|
+
except Exception as exc:
|
|
354
|
+
log_event(
|
|
355
|
+
self._logger,
|
|
356
|
+
logging.WARNING,
|
|
357
|
+
"telegram.thread.verify_failed",
|
|
358
|
+
chat_id=message.chat_id,
|
|
359
|
+
thread_id=message.thread_id,
|
|
360
|
+
codex_thread_id=thread_id,
|
|
361
|
+
exc=exc,
|
|
362
|
+
)
|
|
363
|
+
await self._send_message(
|
|
364
|
+
message.chat_id,
|
|
365
|
+
"Failed to verify the active thread; use /resume or /new.",
|
|
366
|
+
thread_id=message.thread_id,
|
|
367
|
+
reply_to=message.message_id,
|
|
368
|
+
)
|
|
369
|
+
return None
|
|
370
|
+
info = _extract_thread_info(result)
|
|
371
|
+
resumed_path = info.get("workspace_path")
|
|
372
|
+
if not isinstance(resumed_path, str):
|
|
373
|
+
await self._send_message(
|
|
374
|
+
message.chat_id,
|
|
375
|
+
"Active thread missing workspace metadata; refusing to continue. "
|
|
376
|
+
"Fix the app-server workspace reporting and try /new.",
|
|
377
|
+
thread_id=message.thread_id,
|
|
378
|
+
reply_to=message.message_id,
|
|
379
|
+
)
|
|
380
|
+
return await self._router.set_active_thread(
|
|
381
|
+
message.chat_id, message.thread_id, None
|
|
382
|
+
)
|
|
383
|
+
try:
|
|
384
|
+
workspace_root = Path(record.workspace_path or "").expanduser().resolve()
|
|
385
|
+
resumed_root = Path(resumed_path).expanduser().resolve()
|
|
386
|
+
except Exception:
|
|
387
|
+
await self._send_message(
|
|
388
|
+
message.chat_id,
|
|
389
|
+
"Active thread has invalid workspace metadata; refusing to continue. "
|
|
390
|
+
"Fix the app-server workspace reporting and try /new.",
|
|
391
|
+
thread_id=message.thread_id,
|
|
392
|
+
reply_to=message.message_id,
|
|
393
|
+
)
|
|
394
|
+
return await self._router.set_active_thread(
|
|
395
|
+
message.chat_id, message.thread_id, None
|
|
396
|
+
)
|
|
397
|
+
if not _paths_compatible(workspace_root, resumed_root):
|
|
398
|
+
log_event(
|
|
399
|
+
self._logger,
|
|
400
|
+
logging.INFO,
|
|
401
|
+
"telegram.thread.workspace_mismatch",
|
|
402
|
+
chat_id=message.chat_id,
|
|
403
|
+
thread_id=message.thread_id,
|
|
404
|
+
codex_thread_id=thread_id,
|
|
405
|
+
workspace_path=str(workspace_root),
|
|
406
|
+
resumed_path=str(resumed_root),
|
|
407
|
+
)
|
|
408
|
+
await self._send_message(
|
|
409
|
+
message.chat_id,
|
|
410
|
+
"Active thread belongs to a different workspace; refusing to continue. "
|
|
411
|
+
"Fix the app-server workspace reporting and try /new.",
|
|
412
|
+
thread_id=message.thread_id,
|
|
413
|
+
reply_to=message.message_id,
|
|
414
|
+
)
|
|
415
|
+
return await self._router.set_active_thread(
|
|
416
|
+
message.chat_id, message.thread_id, None
|
|
417
|
+
)
|
|
418
|
+
return await self._apply_thread_result(
|
|
419
|
+
message.chat_id, message.thread_id, result, active_thread_id=thread_id
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
async def _find_thread_conflict(self, thread_id: str, *, key: str) -> Optional[str]:
|
|
423
|
+
return await self._store.find_active_thread(thread_id, exclude_key=key)
|
|
424
|
+
|
|
425
|
+
async def _handle_thread_conflict(
|
|
426
|
+
self,
|
|
427
|
+
message: TelegramMessage,
|
|
428
|
+
thread_id: str,
|
|
429
|
+
conflict_key: str,
|
|
430
|
+
) -> None:
|
|
431
|
+
log_event(
|
|
432
|
+
self._logger,
|
|
433
|
+
logging.WARNING,
|
|
434
|
+
"telegram.thread.conflict",
|
|
435
|
+
chat_id=message.chat_id,
|
|
436
|
+
thread_id=message.thread_id,
|
|
437
|
+
codex_thread_id=thread_id,
|
|
438
|
+
conflict_topic=conflict_key,
|
|
439
|
+
)
|
|
440
|
+
await self._send_message(
|
|
441
|
+
message.chat_id,
|
|
442
|
+
"That Codex thread is already active in another topic. "
|
|
443
|
+
"Use /new here or continue in the other topic.",
|
|
444
|
+
thread_id=message.thread_id,
|
|
445
|
+
reply_to=message.message_id,
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
async def _apply_thread_result(
|
|
449
|
+
self,
|
|
450
|
+
chat_id: int,
|
|
451
|
+
thread_id: Optional[int],
|
|
452
|
+
result: Any,
|
|
453
|
+
*,
|
|
454
|
+
active_thread_id: Optional[str] = None,
|
|
455
|
+
overwrite_defaults: bool = False,
|
|
456
|
+
) -> "TelegramTopicRecord":
|
|
457
|
+
info = _extract_thread_info(result)
|
|
458
|
+
if active_thread_id is None:
|
|
459
|
+
active_thread_id = info.get("thread_id")
|
|
460
|
+
user_preview, assistant_preview = _extract_thread_preview_parts(result)
|
|
461
|
+
last_used_at = now_iso()
|
|
462
|
+
|
|
463
|
+
def apply(record: "TelegramTopicRecord") -> None:
|
|
464
|
+
if active_thread_id:
|
|
465
|
+
record.active_thread_id = active_thread_id
|
|
466
|
+
if active_thread_id in record.thread_ids:
|
|
467
|
+
record.thread_ids.remove(active_thread_id)
|
|
468
|
+
record.thread_ids.insert(0, active_thread_id)
|
|
469
|
+
if len(record.thread_ids) > MAX_TOPIC_THREAD_HISTORY:
|
|
470
|
+
record.thread_ids = record.thread_ids[:MAX_TOPIC_THREAD_HISTORY]
|
|
471
|
+
_set_thread_summary(
|
|
472
|
+
record,
|
|
473
|
+
active_thread_id,
|
|
474
|
+
user_preview=user_preview,
|
|
475
|
+
assistant_preview=assistant_preview,
|
|
476
|
+
last_used_at=last_used_at,
|
|
477
|
+
workspace_path=info.get("workspace_path"),
|
|
478
|
+
rollout_path=info.get("rollout_path"),
|
|
479
|
+
)
|
|
480
|
+
incoming_workspace = info.get("workspace_path")
|
|
481
|
+
if isinstance(incoming_workspace, str) and incoming_workspace:
|
|
482
|
+
if record.workspace_path:
|
|
483
|
+
try:
|
|
484
|
+
current_root = canonicalize_path(Path(record.workspace_path))
|
|
485
|
+
incoming_root = canonicalize_path(Path(incoming_workspace))
|
|
486
|
+
except Exception:
|
|
487
|
+
current_root = None
|
|
488
|
+
incoming_root = None
|
|
489
|
+
if (
|
|
490
|
+
current_root is None
|
|
491
|
+
or incoming_root is None
|
|
492
|
+
or not _paths_compatible(current_root, incoming_root)
|
|
493
|
+
):
|
|
494
|
+
log_event(
|
|
495
|
+
self._logger,
|
|
496
|
+
logging.WARNING,
|
|
497
|
+
"telegram.workspace.mismatch",
|
|
498
|
+
workspace_path=record.workspace_path,
|
|
499
|
+
incoming_workspace_path=incoming_workspace,
|
|
500
|
+
)
|
|
501
|
+
else:
|
|
502
|
+
record.workspace_path = incoming_workspace
|
|
503
|
+
else:
|
|
504
|
+
record.workspace_path = incoming_workspace
|
|
505
|
+
record.workspace_id = self._workspace_id_for_path(record.workspace_path)
|
|
506
|
+
if info.get("rollout_path"):
|
|
507
|
+
record.rollout_path = info["rollout_path"]
|
|
508
|
+
if info.get("agent") and (overwrite_defaults or record.agent is None):
|
|
509
|
+
normalized_agent = normalize_agent(info.get("agent"))
|
|
510
|
+
if normalized_agent:
|
|
511
|
+
record.agent = normalized_agent
|
|
512
|
+
if info.get("model") and (overwrite_defaults or record.model is None):
|
|
513
|
+
record.model = info["model"]
|
|
514
|
+
if info.get("effort") and (overwrite_defaults or record.effort is None):
|
|
515
|
+
record.effort = info["effort"]
|
|
516
|
+
if info.get("summary") and (overwrite_defaults or record.summary is None):
|
|
517
|
+
record.summary = info["summary"]
|
|
518
|
+
allow_thread_policies = record.approval_mode != APPROVAL_MODE_YOLO
|
|
519
|
+
if (
|
|
520
|
+
allow_thread_policies
|
|
521
|
+
and info.get("approval_policy")
|
|
522
|
+
and (overwrite_defaults or record.approval_policy is None)
|
|
523
|
+
):
|
|
524
|
+
record.approval_policy = info["approval_policy"]
|
|
525
|
+
if (
|
|
526
|
+
allow_thread_policies
|
|
527
|
+
and info.get("sandbox_policy")
|
|
528
|
+
and (overwrite_defaults or record.sandbox_policy is None)
|
|
529
|
+
):
|
|
530
|
+
record.sandbox_policy = info["sandbox_policy"]
|
|
531
|
+
|
|
532
|
+
return await self._router.update_topic(chat_id, thread_id, apply)
|
|
533
|
+
|
|
534
|
+
async def _require_bound_record(
|
|
535
|
+
self, message: TelegramMessage, *, prompt: Optional[str] = None
|
|
536
|
+
) -> Optional["TelegramTopicRecord"]:
|
|
537
|
+
key = await self._resolve_topic_key(message.chat_id, message.thread_id)
|
|
538
|
+
record = await self._router.get_topic(key)
|
|
539
|
+
if record is None or not record.workspace_path:
|
|
540
|
+
await self._send_message(
|
|
541
|
+
message.chat_id,
|
|
542
|
+
prompt or "Topic not bound. Use /bind <repo_id> or /bind <path>.",
|
|
543
|
+
thread_id=message.thread_id,
|
|
544
|
+
reply_to=message.message_id,
|
|
545
|
+
)
|
|
546
|
+
return None
|
|
547
|
+
await self._refresh_workspace_id(key, record)
|
|
548
|
+
return record
|
|
549
|
+
|
|
550
|
+
async def _ensure_thread_id(
|
|
551
|
+
self, message: TelegramMessage, record: "TelegramTopicRecord"
|
|
552
|
+
) -> Optional[str]:
|
|
553
|
+
thread_id = record.active_thread_id
|
|
554
|
+
if thread_id:
|
|
555
|
+
key = await self._resolve_topic_key(message.chat_id, message.thread_id)
|
|
556
|
+
conflict_key = await self._find_thread_conflict(thread_id, key=key)
|
|
557
|
+
if conflict_key:
|
|
558
|
+
await self._router.set_active_thread(
|
|
559
|
+
message.chat_id, message.thread_id, None
|
|
560
|
+
)
|
|
561
|
+
await self._handle_thread_conflict(message, thread_id, conflict_key)
|
|
562
|
+
return None
|
|
563
|
+
verified = await self._verify_active_thread(message, record)
|
|
564
|
+
if not verified:
|
|
565
|
+
return None
|
|
566
|
+
record = verified
|
|
567
|
+
thread_id = record.active_thread_id
|
|
568
|
+
if thread_id:
|
|
569
|
+
return thread_id
|
|
570
|
+
agent = self._effective_agent(record)
|
|
571
|
+
if agent == "opencode":
|
|
572
|
+
supervisor = getattr(self, "_opencode_supervisor", None)
|
|
573
|
+
if supervisor is None:
|
|
574
|
+
await self._send_message(
|
|
575
|
+
message.chat_id,
|
|
576
|
+
"OpenCode backend unavailable; install opencode or switch to /agent codex.",
|
|
577
|
+
thread_id=message.thread_id,
|
|
578
|
+
reply_to=message.message_id,
|
|
579
|
+
)
|
|
580
|
+
return None
|
|
581
|
+
workspace_root = self._canonical_workspace_root(record.workspace_path)
|
|
582
|
+
if workspace_root is None:
|
|
583
|
+
await self._send_message(
|
|
584
|
+
message.chat_id,
|
|
585
|
+
"Workspace unavailable.",
|
|
586
|
+
thread_id=message.thread_id,
|
|
587
|
+
reply_to=message.message_id,
|
|
588
|
+
)
|
|
589
|
+
return None
|
|
590
|
+
try:
|
|
591
|
+
opencode_client = await supervisor.get_client(workspace_root)
|
|
592
|
+
session = await opencode_client.create_session(
|
|
593
|
+
directory=str(workspace_root)
|
|
594
|
+
)
|
|
595
|
+
except Exception as exc:
|
|
596
|
+
log_event(
|
|
597
|
+
self._logger,
|
|
598
|
+
logging.WARNING,
|
|
599
|
+
"telegram.opencode.session.failed",
|
|
600
|
+
chat_id=message.chat_id,
|
|
601
|
+
thread_id=message.thread_id,
|
|
602
|
+
exc=exc,
|
|
603
|
+
)
|
|
604
|
+
await self._send_message(
|
|
605
|
+
message.chat_id,
|
|
606
|
+
"Failed to start a new OpenCode thread.",
|
|
607
|
+
thread_id=message.thread_id,
|
|
608
|
+
reply_to=message.message_id,
|
|
609
|
+
)
|
|
610
|
+
return None
|
|
611
|
+
session_id = extract_session_id(session, allow_fallback_id=True)
|
|
612
|
+
if not session_id:
|
|
613
|
+
await self._send_message(
|
|
614
|
+
message.chat_id,
|
|
615
|
+
"Failed to start a new OpenCode thread.",
|
|
616
|
+
thread_id=message.thread_id,
|
|
617
|
+
reply_to=message.message_id,
|
|
618
|
+
)
|
|
619
|
+
return None
|
|
620
|
+
|
|
621
|
+
def apply(record: "TelegramTopicRecord") -> None:
|
|
622
|
+
record.active_thread_id = session_id
|
|
623
|
+
if session_id in record.thread_ids:
|
|
624
|
+
record.thread_ids.remove(session_id)
|
|
625
|
+
record.thread_ids.insert(0, session_id)
|
|
626
|
+
if len(record.thread_ids) > MAX_TOPIC_THREAD_HISTORY:
|
|
627
|
+
record.thread_ids = record.thread_ids[:MAX_TOPIC_THREAD_HISTORY]
|
|
628
|
+
_set_thread_summary(
|
|
629
|
+
record,
|
|
630
|
+
session_id,
|
|
631
|
+
last_used_at=now_iso(),
|
|
632
|
+
workspace_path=record.workspace_path,
|
|
633
|
+
rollout_path=record.rollout_path,
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
await self._router.update_topic(message.chat_id, message.thread_id, apply)
|
|
637
|
+
return session_id
|
|
638
|
+
try:
|
|
639
|
+
client = await self._client_for_workspace(record.workspace_path)
|
|
640
|
+
except AppServerUnavailableError as exc:
|
|
641
|
+
log_event(
|
|
642
|
+
self._logger,
|
|
643
|
+
logging.WARNING,
|
|
644
|
+
"telegram.app_server.unavailable",
|
|
645
|
+
chat_id=message.chat_id,
|
|
646
|
+
thread_id=message.thread_id,
|
|
647
|
+
exc=exc,
|
|
648
|
+
)
|
|
649
|
+
await self._send_message(
|
|
650
|
+
message.chat_id,
|
|
651
|
+
"App server unavailable; try again or check logs.",
|
|
652
|
+
thread_id=message.thread_id,
|
|
653
|
+
reply_to=message.message_id,
|
|
654
|
+
)
|
|
655
|
+
return None
|
|
656
|
+
if client is None:
|
|
657
|
+
await self._send_message(
|
|
658
|
+
message.chat_id,
|
|
659
|
+
"Topic not bound. Use /bind <repo_id> or /bind <path>.",
|
|
660
|
+
thread_id=message.thread_id,
|
|
661
|
+
reply_to=message.message_id,
|
|
662
|
+
)
|
|
663
|
+
return None
|
|
664
|
+
thread = await client.thread_start(record.workspace_path or "", agent=agent)
|
|
665
|
+
if not await self._require_thread_workspace(
|
|
666
|
+
message, record.workspace_path, thread, action="thread_start"
|
|
667
|
+
):
|
|
668
|
+
return None
|
|
669
|
+
thread_id = _extract_thread_id(thread)
|
|
670
|
+
if not thread_id:
|
|
671
|
+
await self._send_message(
|
|
672
|
+
message.chat_id,
|
|
673
|
+
"Failed to start a new thread.",
|
|
674
|
+
thread_id=message.thread_id,
|
|
675
|
+
reply_to=message.message_id,
|
|
676
|
+
)
|
|
677
|
+
return None
|
|
678
|
+
await self._apply_thread_result(
|
|
679
|
+
message.chat_id,
|
|
680
|
+
message.thread_id,
|
|
681
|
+
thread,
|
|
682
|
+
active_thread_id=thread_id,
|
|
683
|
+
)
|
|
684
|
+
return thread_id
|
|
685
|
+
|
|
686
|
+
def _list_manifest_repos(self) -> list[str]:
|
|
687
|
+
if not self._manifest_path or not self._hub_root:
|
|
688
|
+
return []
|
|
689
|
+
try:
|
|
690
|
+
manifest = load_manifest(self._manifest_path, self._hub_root)
|
|
691
|
+
except Exception:
|
|
692
|
+
return []
|
|
693
|
+
repo_ids = [repo.id for repo in manifest.repos if repo.enabled]
|
|
694
|
+
return repo_ids
|
|
695
|
+
|
|
696
|
+
def _resolve_workspace(self, arg: str) -> Optional[tuple[str, Optional[str]]]:
|
|
697
|
+
arg = (arg or "").strip()
|
|
698
|
+
if not arg:
|
|
699
|
+
return None
|
|
700
|
+
if self._manifest_path and self._hub_root:
|
|
701
|
+
try:
|
|
702
|
+
manifest = load_manifest(self._manifest_path, self._hub_root)
|
|
703
|
+
repo = manifest.get(arg)
|
|
704
|
+
if repo:
|
|
705
|
+
workspace = canonicalize_path(self._hub_root / repo.path)
|
|
706
|
+
return str(workspace), repo.id
|
|
707
|
+
except Exception:
|
|
708
|
+
pass
|
|
709
|
+
path = Path(arg)
|
|
710
|
+
if not path.is_absolute():
|
|
711
|
+
path = canonicalize_path(self._config.root / path)
|
|
712
|
+
else:
|
|
713
|
+
try:
|
|
714
|
+
path = canonicalize_path(path)
|
|
715
|
+
except Exception:
|
|
716
|
+
return None
|
|
717
|
+
if path.exists():
|
|
718
|
+
return str(path), None
|
|
719
|
+
return None
|
|
720
|
+
|
|
721
|
+
async def _require_thread_workspace(
|
|
722
|
+
self,
|
|
723
|
+
message: TelegramMessage,
|
|
724
|
+
expected_workspace: Optional[str],
|
|
725
|
+
result: Any,
|
|
726
|
+
*,
|
|
727
|
+
action: str,
|
|
728
|
+
) -> bool:
|
|
729
|
+
if not expected_workspace:
|
|
730
|
+
return True
|
|
731
|
+
info = _extract_thread_info(result)
|
|
732
|
+
incoming = info.get("workspace_path")
|
|
733
|
+
if not isinstance(incoming, str) or not incoming:
|
|
734
|
+
log_event(
|
|
735
|
+
self._logger,
|
|
736
|
+
logging.WARNING,
|
|
737
|
+
"telegram.thread.workspace_missing",
|
|
738
|
+
action=action,
|
|
739
|
+
expected_workspace=expected_workspace,
|
|
740
|
+
)
|
|
741
|
+
await self._send_message(
|
|
742
|
+
message.chat_id,
|
|
743
|
+
"App server did not return a workspace for this thread. "
|
|
744
|
+
"Refusing to continue; fix the app-server workspace reporting and "
|
|
745
|
+
"try /new.",
|
|
746
|
+
thread_id=message.thread_id,
|
|
747
|
+
reply_to=message.message_id,
|
|
748
|
+
)
|
|
749
|
+
return False
|
|
750
|
+
try:
|
|
751
|
+
expected_root = Path(expected_workspace).expanduser().resolve()
|
|
752
|
+
incoming_root = Path(incoming).expanduser().resolve()
|
|
753
|
+
except Exception:
|
|
754
|
+
expected_root = None
|
|
755
|
+
incoming_root = None
|
|
756
|
+
if (
|
|
757
|
+
expected_root is None
|
|
758
|
+
or incoming_root is None
|
|
759
|
+
or not _paths_compatible(expected_root, incoming_root)
|
|
760
|
+
):
|
|
761
|
+
log_event(
|
|
762
|
+
self._logger,
|
|
763
|
+
logging.WARNING,
|
|
764
|
+
"telegram.thread.workspace_mismatch",
|
|
765
|
+
action=action,
|
|
766
|
+
expected_workspace=expected_workspace,
|
|
767
|
+
incoming_workspace=incoming,
|
|
768
|
+
)
|
|
769
|
+
await self._send_message(
|
|
770
|
+
message.chat_id,
|
|
771
|
+
"App server returned a thread for a different workspace. "
|
|
772
|
+
"Refusing to continue; fix the app-server workspace reporting and "
|
|
773
|
+
"try /new.",
|
|
774
|
+
thread_id=message.thread_id,
|
|
775
|
+
reply_to=message.message_id,
|
|
776
|
+
)
|
|
777
|
+
return False
|
|
778
|
+
return True
|
|
779
|
+
|
|
780
|
+
async def _handle_bind(self, message: TelegramMessage, args: str) -> None:
|
|
781
|
+
key = await self._resolve_topic_key(message.chat_id, message.thread_id)
|
|
782
|
+
if not args:
|
|
783
|
+
options = self._list_manifest_repos()
|
|
784
|
+
if not options:
|
|
785
|
+
await self._send_message(
|
|
786
|
+
message.chat_id,
|
|
787
|
+
"Usage: /bind <repo_id> or /bind <path>.",
|
|
788
|
+
thread_id=message.thread_id,
|
|
789
|
+
reply_to=message.message_id,
|
|
790
|
+
)
|
|
791
|
+
return
|
|
792
|
+
items = [(repo_id, repo_id) for repo_id in options]
|
|
793
|
+
state = SelectionState(items=items)
|
|
794
|
+
keyboard = self._build_bind_keyboard(state)
|
|
795
|
+
self._bind_options[key] = state
|
|
796
|
+
self._touch_cache_timestamp("bind_options", key)
|
|
797
|
+
await self._send_message(
|
|
798
|
+
message.chat_id,
|
|
799
|
+
self._selection_prompt(BIND_PICKER_PROMPT, state),
|
|
800
|
+
thread_id=message.thread_id,
|
|
801
|
+
reply_to=message.message_id,
|
|
802
|
+
reply_markup=keyboard,
|
|
803
|
+
)
|
|
804
|
+
return
|
|
805
|
+
await self._bind_topic_with_arg(key, args, message)
|
|
806
|
+
|
|
807
|
+
async def _bind_topic_by_repo_id(
|
|
808
|
+
self,
|
|
809
|
+
key: str,
|
|
810
|
+
repo_id: str,
|
|
811
|
+
callback: Optional[TelegramCallbackQuery] = None,
|
|
812
|
+
) -> None:
|
|
813
|
+
self._bind_options.pop(key, None)
|
|
814
|
+
resolved = self._resolve_workspace(repo_id)
|
|
815
|
+
if resolved is None:
|
|
816
|
+
await self._answer_callback(callback, "Repo not found")
|
|
817
|
+
await self._finalize_selection(key, callback, "Repo not found.")
|
|
818
|
+
return
|
|
819
|
+
workspace_path, resolved_repo_id = resolved
|
|
820
|
+
chat_id, thread_id = _split_topic_key(key)
|
|
821
|
+
scope = self._topic_scope_id(resolved_repo_id, workspace_path)
|
|
822
|
+
await self._router.set_topic_scope(chat_id, thread_id, scope)
|
|
823
|
+
await self._router.bind_topic(
|
|
824
|
+
chat_id,
|
|
825
|
+
thread_id,
|
|
826
|
+
workspace_path,
|
|
827
|
+
repo_id=resolved_repo_id,
|
|
828
|
+
scope=scope,
|
|
829
|
+
)
|
|
830
|
+
workspace_id = self._workspace_id_for_path(workspace_path)
|
|
831
|
+
if workspace_id:
|
|
832
|
+
await self._router.update_topic(
|
|
833
|
+
chat_id,
|
|
834
|
+
thread_id,
|
|
835
|
+
lambda record: setattr(record, "workspace_id", workspace_id),
|
|
836
|
+
scope=scope,
|
|
837
|
+
)
|
|
838
|
+
await self._answer_callback(callback, "Bound to repo")
|
|
839
|
+
await self._finalize_selection(
|
|
840
|
+
key,
|
|
841
|
+
callback,
|
|
842
|
+
f"Bound to {resolved_repo_id or workspace_path}.",
|
|
843
|
+
)
|
|
844
|
+
|
|
845
|
+
async def _bind_topic_with_arg(
|
|
846
|
+
self, key: str, arg: str, message: TelegramMessage
|
|
847
|
+
) -> None:
|
|
848
|
+
self._bind_options.pop(key, None)
|
|
849
|
+
resolved = self._resolve_workspace(arg)
|
|
850
|
+
if resolved is None:
|
|
851
|
+
await self._send_message(
|
|
852
|
+
message.chat_id,
|
|
853
|
+
"Unknown repo or path. Use /bind <repo_id> or /bind <path>.",
|
|
854
|
+
thread_id=message.thread_id,
|
|
855
|
+
reply_to=message.message_id,
|
|
856
|
+
)
|
|
857
|
+
return
|
|
858
|
+
workspace_path, repo_id = resolved
|
|
859
|
+
scope = self._topic_scope_id(repo_id, workspace_path)
|
|
860
|
+
await self._router.set_topic_scope(message.chat_id, message.thread_id, scope)
|
|
861
|
+
await self._router.bind_topic(
|
|
862
|
+
message.chat_id,
|
|
863
|
+
message.thread_id,
|
|
864
|
+
workspace_path,
|
|
865
|
+
repo_id=repo_id,
|
|
866
|
+
scope=scope,
|
|
867
|
+
)
|
|
868
|
+
workspace_id = self._workspace_id_for_path(workspace_path)
|
|
869
|
+
if workspace_id:
|
|
870
|
+
await self._router.update_topic(
|
|
871
|
+
message.chat_id,
|
|
872
|
+
message.thread_id,
|
|
873
|
+
lambda record: setattr(record, "workspace_id", workspace_id),
|
|
874
|
+
scope=scope,
|
|
875
|
+
)
|
|
876
|
+
await self._send_message(
|
|
877
|
+
message.chat_id,
|
|
878
|
+
f"Bound to {repo_id or workspace_path}.",
|
|
879
|
+
thread_id=message.thread_id,
|
|
880
|
+
reply_to=message.message_id,
|
|
881
|
+
)
|
|
882
|
+
|
|
883
|
+
async def _handle_new(self, message: TelegramMessage) -> None:
|
|
884
|
+
key = await self._resolve_topic_key(message.chat_id, message.thread_id)
|
|
885
|
+
record = await self._router.get_topic(key)
|
|
886
|
+
if record is None or not record.workspace_path:
|
|
887
|
+
await self._send_message(
|
|
888
|
+
message.chat_id,
|
|
889
|
+
"Topic not bound. Use /bind <repo_id> or /bind <path>.",
|
|
890
|
+
thread_id=message.thread_id,
|
|
891
|
+
reply_to=message.message_id,
|
|
892
|
+
)
|
|
893
|
+
return
|
|
894
|
+
agent = self._effective_agent(record)
|
|
895
|
+
if agent == "opencode":
|
|
896
|
+
supervisor = getattr(self, "_opencode_supervisor", None)
|
|
897
|
+
if supervisor is None:
|
|
898
|
+
await self._send_message(
|
|
899
|
+
message.chat_id,
|
|
900
|
+
"OpenCode backend unavailable; install opencode or switch to /agent codex.",
|
|
901
|
+
thread_id=message.thread_id,
|
|
902
|
+
reply_to=message.message_id,
|
|
903
|
+
)
|
|
904
|
+
return
|
|
905
|
+
workspace_root = self._canonical_workspace_root(record.workspace_path)
|
|
906
|
+
if workspace_root is None:
|
|
907
|
+
await self._send_message(
|
|
908
|
+
message.chat_id,
|
|
909
|
+
"Workspace unavailable.",
|
|
910
|
+
thread_id=message.thread_id,
|
|
911
|
+
reply_to=message.message_id,
|
|
912
|
+
)
|
|
913
|
+
return
|
|
914
|
+
try:
|
|
915
|
+
client = await supervisor.get_client(workspace_root)
|
|
916
|
+
session = await client.create_session(directory=str(workspace_root))
|
|
917
|
+
except Exception as exc:
|
|
918
|
+
log_event(
|
|
919
|
+
self._logger,
|
|
920
|
+
logging.WARNING,
|
|
921
|
+
"telegram.opencode.session.failed",
|
|
922
|
+
chat_id=message.chat_id,
|
|
923
|
+
thread_id=message.thread_id,
|
|
924
|
+
exc=exc,
|
|
925
|
+
)
|
|
926
|
+
await self._send_message(
|
|
927
|
+
message.chat_id,
|
|
928
|
+
"Failed to start a new OpenCode thread.",
|
|
929
|
+
thread_id=message.thread_id,
|
|
930
|
+
reply_to=message.message_id,
|
|
931
|
+
)
|
|
932
|
+
return
|
|
933
|
+
session_id = extract_session_id(session, allow_fallback_id=True)
|
|
934
|
+
if not session_id:
|
|
935
|
+
await self._send_message(
|
|
936
|
+
message.chat_id,
|
|
937
|
+
"Failed to start a new OpenCode thread.",
|
|
938
|
+
thread_id=message.thread_id,
|
|
939
|
+
reply_to=message.message_id,
|
|
940
|
+
)
|
|
941
|
+
return
|
|
942
|
+
|
|
943
|
+
def apply(record: "TelegramTopicRecord") -> None:
|
|
944
|
+
record.active_thread_id = session_id
|
|
945
|
+
if session_id in record.thread_ids:
|
|
946
|
+
record.thread_ids.remove(session_id)
|
|
947
|
+
record.thread_ids.insert(0, session_id)
|
|
948
|
+
if len(record.thread_ids) > MAX_TOPIC_THREAD_HISTORY:
|
|
949
|
+
record.thread_ids = record.thread_ids[:MAX_TOPIC_THREAD_HISTORY]
|
|
950
|
+
_set_thread_summary(
|
|
951
|
+
record,
|
|
952
|
+
session_id,
|
|
953
|
+
last_used_at=now_iso(),
|
|
954
|
+
workspace_path=record.workspace_path,
|
|
955
|
+
rollout_path=record.rollout_path,
|
|
956
|
+
)
|
|
957
|
+
|
|
958
|
+
await self._router.update_topic(message.chat_id, message.thread_id, apply)
|
|
959
|
+
thread_id = session_id
|
|
960
|
+
else:
|
|
961
|
+
try:
|
|
962
|
+
client = await self._client_for_workspace(record.workspace_path)
|
|
963
|
+
except AppServerUnavailableError as exc:
|
|
964
|
+
log_event(
|
|
965
|
+
self._logger,
|
|
966
|
+
logging.WARNING,
|
|
967
|
+
"telegram.app_server.unavailable",
|
|
968
|
+
chat_id=message.chat_id,
|
|
969
|
+
thread_id=message.thread_id,
|
|
970
|
+
exc=exc,
|
|
971
|
+
)
|
|
972
|
+
await self._send_message(
|
|
973
|
+
message.chat_id,
|
|
974
|
+
"App server unavailable; try again or check logs.",
|
|
975
|
+
thread_id=message.thread_id,
|
|
976
|
+
reply_to=message.message_id,
|
|
977
|
+
)
|
|
978
|
+
return
|
|
979
|
+
if client is None:
|
|
980
|
+
await self._send_message(
|
|
981
|
+
message.chat_id,
|
|
982
|
+
"Topic not bound. Use /bind <repo_id> or /bind <path>.",
|
|
983
|
+
thread_id=message.thread_id,
|
|
984
|
+
reply_to=message.message_id,
|
|
985
|
+
)
|
|
986
|
+
return
|
|
987
|
+
thread = await client.thread_start(record.workspace_path, agent=agent)
|
|
988
|
+
if not await self._require_thread_workspace(
|
|
989
|
+
message, record.workspace_path, thread, action="thread_start"
|
|
990
|
+
):
|
|
991
|
+
return
|
|
992
|
+
thread_id = _extract_thread_id(thread)
|
|
993
|
+
if not thread_id:
|
|
994
|
+
await self._send_message(
|
|
995
|
+
message.chat_id,
|
|
996
|
+
"Failed to start a new thread.",
|
|
997
|
+
thread_id=message.thread_id,
|
|
998
|
+
reply_to=message.message_id,
|
|
999
|
+
)
|
|
1000
|
+
return
|
|
1001
|
+
await self._apply_thread_result(
|
|
1002
|
+
message.chat_id, message.thread_id, thread, active_thread_id=thread_id
|
|
1003
|
+
)
|
|
1004
|
+
effort_label = (
|
|
1005
|
+
record.effort or "default" if self._agent_supports_effort(agent) else "n/a"
|
|
1006
|
+
)
|
|
1007
|
+
await self._send_message(
|
|
1008
|
+
message.chat_id,
|
|
1009
|
+
"\n".join(
|
|
1010
|
+
[
|
|
1011
|
+
f"Started new thread {thread_id}.",
|
|
1012
|
+
f"Directory: {record.workspace_path or 'unbound'}",
|
|
1013
|
+
f"Agent: {agent}",
|
|
1014
|
+
f"Model: {record.model or 'default'}",
|
|
1015
|
+
f"Effort: {effort_label}",
|
|
1016
|
+
]
|
|
1017
|
+
),
|
|
1018
|
+
thread_id=message.thread_id,
|
|
1019
|
+
reply_to=message.message_id,
|
|
1020
|
+
)
|
|
1021
|
+
|
|
1022
|
+
async def _handle_opencode_resume(
|
|
1023
|
+
self,
|
|
1024
|
+
message: TelegramMessage,
|
|
1025
|
+
record: "TelegramTopicRecord",
|
|
1026
|
+
*,
|
|
1027
|
+
key: str,
|
|
1028
|
+
show_unscoped: bool,
|
|
1029
|
+
refresh: bool,
|
|
1030
|
+
) -> None:
|
|
1031
|
+
if refresh:
|
|
1032
|
+
log_event(
|
|
1033
|
+
self._logger,
|
|
1034
|
+
logging.INFO,
|
|
1035
|
+
"telegram.opencode.resume.refresh_ignored",
|
|
1036
|
+
chat_id=message.chat_id,
|
|
1037
|
+
thread_id=message.thread_id,
|
|
1038
|
+
)
|
|
1039
|
+
local_thread_ids: list[str] = []
|
|
1040
|
+
local_previews: dict[str, str] = {}
|
|
1041
|
+
local_thread_topics: dict[str, set[str]] = {}
|
|
1042
|
+
store_state = None
|
|
1043
|
+
if show_unscoped:
|
|
1044
|
+
store_state = await self._store.load()
|
|
1045
|
+
(
|
|
1046
|
+
local_thread_ids,
|
|
1047
|
+
local_previews,
|
|
1048
|
+
local_thread_topics,
|
|
1049
|
+
) = _local_workspace_threads(
|
|
1050
|
+
store_state, record.workspace_path, current_key=key
|
|
1051
|
+
)
|
|
1052
|
+
for thread_id in record.thread_ids:
|
|
1053
|
+
local_thread_topics.setdefault(thread_id, set()).add(key)
|
|
1054
|
+
if thread_id not in local_thread_ids:
|
|
1055
|
+
local_thread_ids.append(thread_id)
|
|
1056
|
+
cached_preview = _thread_summary_preview(record, thread_id)
|
|
1057
|
+
if cached_preview:
|
|
1058
|
+
local_previews.setdefault(thread_id, cached_preview)
|
|
1059
|
+
allowed_thread_ids: set[str] = set()
|
|
1060
|
+
for thread_id in local_thread_ids:
|
|
1061
|
+
if thread_id in record.thread_ids:
|
|
1062
|
+
allowed_thread_ids.add(thread_id)
|
|
1063
|
+
continue
|
|
1064
|
+
for topic_key in local_thread_topics.get(thread_id, set()):
|
|
1065
|
+
topic_record = (
|
|
1066
|
+
store_state.topics.get(topic_key) if store_state else None
|
|
1067
|
+
)
|
|
1068
|
+
if topic_record and topic_record.agent == "opencode":
|
|
1069
|
+
allowed_thread_ids.add(thread_id)
|
|
1070
|
+
break
|
|
1071
|
+
if allowed_thread_ids:
|
|
1072
|
+
local_thread_ids = [
|
|
1073
|
+
thread_id
|
|
1074
|
+
for thread_id in local_thread_ids
|
|
1075
|
+
if thread_id in allowed_thread_ids
|
|
1076
|
+
]
|
|
1077
|
+
local_previews = {
|
|
1078
|
+
thread_id: preview
|
|
1079
|
+
for thread_id, preview in local_previews.items()
|
|
1080
|
+
if thread_id in allowed_thread_ids
|
|
1081
|
+
}
|
|
1082
|
+
else:
|
|
1083
|
+
local_thread_ids = []
|
|
1084
|
+
local_previews = {}
|
|
1085
|
+
else:
|
|
1086
|
+
for thread_id in record.thread_ids:
|
|
1087
|
+
local_thread_ids.append(thread_id)
|
|
1088
|
+
cached_preview = _thread_summary_preview(record, thread_id)
|
|
1089
|
+
if cached_preview:
|
|
1090
|
+
local_previews.setdefault(thread_id, cached_preview)
|
|
1091
|
+
if not local_thread_ids:
|
|
1092
|
+
await self._send_message(
|
|
1093
|
+
message.chat_id,
|
|
1094
|
+
_with_conversation_id(
|
|
1095
|
+
"No previous OpenCode threads found for this topic. "
|
|
1096
|
+
"Use /new to start one.",
|
|
1097
|
+
chat_id=message.chat_id,
|
|
1098
|
+
thread_id=message.thread_id,
|
|
1099
|
+
),
|
|
1100
|
+
thread_id=message.thread_id,
|
|
1101
|
+
reply_to=message.message_id,
|
|
1102
|
+
)
|
|
1103
|
+
return
|
|
1104
|
+
items: list[tuple[str, str]] = []
|
|
1105
|
+
seen_ids: set[str] = set()
|
|
1106
|
+
for thread_id in local_thread_ids:
|
|
1107
|
+
if thread_id in seen_ids:
|
|
1108
|
+
continue
|
|
1109
|
+
seen_ids.add(thread_id)
|
|
1110
|
+
preview = local_previews.get(thread_id)
|
|
1111
|
+
label = _format_missing_thread_label(thread_id, preview)
|
|
1112
|
+
items.append((thread_id, label))
|
|
1113
|
+
state = SelectionState(items=items)
|
|
1114
|
+
keyboard = self._build_resume_keyboard(state)
|
|
1115
|
+
self._resume_options[key] = state
|
|
1116
|
+
self._touch_cache_timestamp("resume_options", key)
|
|
1117
|
+
await self._send_message(
|
|
1118
|
+
message.chat_id,
|
|
1119
|
+
self._selection_prompt(RESUME_PICKER_PROMPT, state),
|
|
1120
|
+
thread_id=message.thread_id,
|
|
1121
|
+
reply_to=message.message_id,
|
|
1122
|
+
reply_markup=keyboard,
|
|
1123
|
+
)
|
|
1124
|
+
|
|
1125
|
+
async def _handle_resume(self, message: TelegramMessage, args: str) -> None:
|
|
1126
|
+
key = await self._resolve_topic_key(message.chat_id, message.thread_id)
|
|
1127
|
+
parsed_args = self._parse_resume_args(args)
|
|
1128
|
+
if await self._handle_resume_shortcuts(key, message, parsed_args):
|
|
1129
|
+
return
|
|
1130
|
+
record = await self._router.get_topic(key)
|
|
1131
|
+
record = await self._ensure_resume_record(message, record)
|
|
1132
|
+
if record is None:
|
|
1133
|
+
return
|
|
1134
|
+
if self._effective_agent(record) == "opencode":
|
|
1135
|
+
await self._handle_opencode_resume(
|
|
1136
|
+
message,
|
|
1137
|
+
record,
|
|
1138
|
+
key=key,
|
|
1139
|
+
show_unscoped=parsed_args.show_unscoped,
|
|
1140
|
+
refresh=parsed_args.refresh,
|
|
1141
|
+
)
|
|
1142
|
+
return
|
|
1143
|
+
client = await self._get_resume_client(message, record)
|
|
1144
|
+
if client is None:
|
|
1145
|
+
return
|
|
1146
|
+
thread_data = await self._gather_resume_threads(
|
|
1147
|
+
message,
|
|
1148
|
+
record,
|
|
1149
|
+
client,
|
|
1150
|
+
key=key,
|
|
1151
|
+
show_unscoped=parsed_args.show_unscoped,
|
|
1152
|
+
)
|
|
1153
|
+
if thread_data is None:
|
|
1154
|
+
return
|
|
1155
|
+
await self._render_resume_picker(
|
|
1156
|
+
message,
|
|
1157
|
+
record,
|
|
1158
|
+
key,
|
|
1159
|
+
parsed_args,
|
|
1160
|
+
thread_data,
|
|
1161
|
+
client,
|
|
1162
|
+
)
|
|
1163
|
+
|
|
1164
|
+
def _parse_resume_args(self, args: str) -> ResumeCommandArgs:
|
|
1165
|
+
"""Parse /resume arguments into structured values."""
|
|
1166
|
+
argv = self._parse_command_args(args)
|
|
1167
|
+
trimmed = args.strip()
|
|
1168
|
+
show_unscoped = False
|
|
1169
|
+
refresh = False
|
|
1170
|
+
remaining: list[str] = []
|
|
1171
|
+
for arg in argv:
|
|
1172
|
+
lowered = arg.lower()
|
|
1173
|
+
if lowered in ("--all", "all", "--unscoped", "unscoped"):
|
|
1174
|
+
show_unscoped = True
|
|
1175
|
+
continue
|
|
1176
|
+
if lowered in ("--refresh", "refresh"):
|
|
1177
|
+
refresh = True
|
|
1178
|
+
continue
|
|
1179
|
+
remaining.append(arg)
|
|
1180
|
+
if argv:
|
|
1181
|
+
trimmed = " ".join(remaining).strip()
|
|
1182
|
+
return ResumeCommandArgs(
|
|
1183
|
+
trimmed=trimmed,
|
|
1184
|
+
remaining=remaining,
|
|
1185
|
+
show_unscoped=show_unscoped,
|
|
1186
|
+
refresh=refresh,
|
|
1187
|
+
)
|
|
1188
|
+
|
|
1189
|
+
async def _handle_resume_shortcuts(
|
|
1190
|
+
self, key: str, message: TelegramMessage, args: ResumeCommandArgs
|
|
1191
|
+
) -> bool:
|
|
1192
|
+
"""Handle numeric or explicit thread selections before listing threads."""
|
|
1193
|
+
trimmed = args.trimmed
|
|
1194
|
+
if trimmed.isdigit():
|
|
1195
|
+
state = self._resume_options.get(key)
|
|
1196
|
+
if state:
|
|
1197
|
+
page_items = _page_slice(state.items, state.page, DEFAULT_PAGE_SIZE)
|
|
1198
|
+
choice = int(trimmed)
|
|
1199
|
+
if 0 < choice <= len(page_items):
|
|
1200
|
+
thread_id = page_items[choice - 1][0]
|
|
1201
|
+
await self._resume_thread_by_id(key, thread_id)
|
|
1202
|
+
return True
|
|
1203
|
+
if trimmed and not trimmed.isdigit():
|
|
1204
|
+
if args.remaining and args.remaining[0].lower() in ("list", "ls"):
|
|
1205
|
+
return False
|
|
1206
|
+
await self._resume_thread_by_id(key, trimmed)
|
|
1207
|
+
return True
|
|
1208
|
+
return False
|
|
1209
|
+
|
|
1210
|
+
async def _ensure_resume_record(
|
|
1211
|
+
self, message: TelegramMessage, record: Optional["TelegramTopicRecord"]
|
|
1212
|
+
) -> Optional["TelegramTopicRecord"]:
|
|
1213
|
+
"""Validate resume preconditions and return the topic record."""
|
|
1214
|
+
if record is None or not record.workspace_path:
|
|
1215
|
+
await self._send_message(
|
|
1216
|
+
message.chat_id,
|
|
1217
|
+
"Topic not bound. Use /bind <repo_id> or /bind <path>.",
|
|
1218
|
+
thread_id=message.thread_id,
|
|
1219
|
+
reply_to=message.message_id,
|
|
1220
|
+
)
|
|
1221
|
+
return None
|
|
1222
|
+
agent = self._effective_agent(record)
|
|
1223
|
+
if not self._agent_supports_resume(agent):
|
|
1224
|
+
await self._send_message(
|
|
1225
|
+
message.chat_id,
|
|
1226
|
+
"Resume is only supported for the codex and opencode agents. Use /agent codex or /agent opencode to switch.",
|
|
1227
|
+
thread_id=message.thread_id,
|
|
1228
|
+
reply_to=message.message_id,
|
|
1229
|
+
)
|
|
1230
|
+
return None
|
|
1231
|
+
return record
|
|
1232
|
+
|
|
1233
|
+
async def _get_resume_client(
|
|
1234
|
+
self, message: TelegramMessage, record: "TelegramTopicRecord"
|
|
1235
|
+
) -> Optional[CodexAppServerClient]:
|
|
1236
|
+
"""Resolve the app server client for the topic workspace."""
|
|
1237
|
+
try:
|
|
1238
|
+
client = await self._client_for_workspace(record.workspace_path)
|
|
1239
|
+
except AppServerUnavailableError as exc:
|
|
1240
|
+
log_event(
|
|
1241
|
+
self._logger,
|
|
1242
|
+
logging.WARNING,
|
|
1243
|
+
"telegram.app_server.unavailable",
|
|
1244
|
+
chat_id=message.chat_id,
|
|
1245
|
+
thread_id=message.thread_id,
|
|
1246
|
+
exc=exc,
|
|
1247
|
+
)
|
|
1248
|
+
await self._send_message(
|
|
1249
|
+
message.chat_id,
|
|
1250
|
+
"App server unavailable; try again or check logs.",
|
|
1251
|
+
thread_id=message.thread_id,
|
|
1252
|
+
reply_to=message.message_id,
|
|
1253
|
+
)
|
|
1254
|
+
return None
|
|
1255
|
+
if client is None:
|
|
1256
|
+
await self._send_message(
|
|
1257
|
+
message.chat_id,
|
|
1258
|
+
"Topic not bound. Use /bind <repo_id> or /bind <path>.",
|
|
1259
|
+
thread_id=message.thread_id,
|
|
1260
|
+
reply_to=message.message_id,
|
|
1261
|
+
)
|
|
1262
|
+
return None
|
|
1263
|
+
return client
|
|
1264
|
+
|
|
1265
|
+
async def _gather_resume_threads(
|
|
1266
|
+
self,
|
|
1267
|
+
message: TelegramMessage,
|
|
1268
|
+
record: "TelegramTopicRecord",
|
|
1269
|
+
client: CodexAppServerClient,
|
|
1270
|
+
*,
|
|
1271
|
+
key: str,
|
|
1272
|
+
show_unscoped: bool,
|
|
1273
|
+
) -> Optional[ResumeThreadData]:
|
|
1274
|
+
"""Collect local and remote threads for the resume picker."""
|
|
1275
|
+
if not show_unscoped and not record.thread_ids:
|
|
1276
|
+
await self._send_message(
|
|
1277
|
+
message.chat_id,
|
|
1278
|
+
"No previous threads found for this topic. Use /new to start one.",
|
|
1279
|
+
thread_id=message.thread_id,
|
|
1280
|
+
reply_to=message.message_id,
|
|
1281
|
+
)
|
|
1282
|
+
return None
|
|
1283
|
+
threads: list[dict[str, Any]] = []
|
|
1284
|
+
list_failed = False
|
|
1285
|
+
local_thread_ids: list[str] = []
|
|
1286
|
+
local_previews: dict[str, str] = {}
|
|
1287
|
+
local_thread_topics: dict[str, set[str]] = {}
|
|
1288
|
+
if show_unscoped:
|
|
1289
|
+
store_state = await self._store.load()
|
|
1290
|
+
(
|
|
1291
|
+
local_thread_ids,
|
|
1292
|
+
local_previews,
|
|
1293
|
+
local_thread_topics,
|
|
1294
|
+
) = _local_workspace_threads(
|
|
1295
|
+
store_state, record.workspace_path, current_key=key
|
|
1296
|
+
)
|
|
1297
|
+
for thread_id in record.thread_ids:
|
|
1298
|
+
local_thread_topics.setdefault(thread_id, set()).add(key)
|
|
1299
|
+
if thread_id not in local_thread_ids:
|
|
1300
|
+
local_thread_ids.append(thread_id)
|
|
1301
|
+
cached_preview = _thread_summary_preview(record, thread_id)
|
|
1302
|
+
if cached_preview:
|
|
1303
|
+
local_previews.setdefault(thread_id, cached_preview)
|
|
1304
|
+
limit = _resume_thread_list_limit(record.thread_ids)
|
|
1305
|
+
needed_ids = (
|
|
1306
|
+
None if show_unscoped or not record.thread_ids else set(record.thread_ids)
|
|
1307
|
+
)
|
|
1308
|
+
try:
|
|
1309
|
+
threads, _ = await self._list_threads_paginated(
|
|
1310
|
+
client,
|
|
1311
|
+
limit=limit,
|
|
1312
|
+
max_pages=THREAD_LIST_MAX_PAGES,
|
|
1313
|
+
needed_ids=needed_ids,
|
|
1314
|
+
)
|
|
1315
|
+
except Exception as exc:
|
|
1316
|
+
list_failed = True
|
|
1317
|
+
log_event(
|
|
1318
|
+
self._logger,
|
|
1319
|
+
logging.WARNING,
|
|
1320
|
+
"telegram.resume.failed",
|
|
1321
|
+
chat_id=message.chat_id,
|
|
1322
|
+
thread_id=message.thread_id,
|
|
1323
|
+
exc=exc,
|
|
1324
|
+
)
|
|
1325
|
+
if show_unscoped and not local_thread_ids:
|
|
1326
|
+
await self._send_message(
|
|
1327
|
+
message.chat_id,
|
|
1328
|
+
_with_conversation_id(
|
|
1329
|
+
"Failed to list threads; check logs for details.",
|
|
1330
|
+
chat_id=message.chat_id,
|
|
1331
|
+
thread_id=message.thread_id,
|
|
1332
|
+
),
|
|
1333
|
+
thread_id=message.thread_id,
|
|
1334
|
+
reply_to=message.message_id,
|
|
1335
|
+
)
|
|
1336
|
+
return None
|
|
1337
|
+
entries_by_id: dict[str, dict[str, Any]] = {}
|
|
1338
|
+
for entry in threads:
|
|
1339
|
+
if not isinstance(entry, dict):
|
|
1340
|
+
continue
|
|
1341
|
+
entry_id = entry.get("id")
|
|
1342
|
+
if isinstance(entry_id, str):
|
|
1343
|
+
entries_by_id[entry_id] = entry
|
|
1344
|
+
candidates: list[dict[str, Any]] = []
|
|
1345
|
+
unscoped_entries: list[dict[str, Any]] = []
|
|
1346
|
+
saw_path = False
|
|
1347
|
+
if show_unscoped:
|
|
1348
|
+
if threads:
|
|
1349
|
+
filtered, unscoped_entries, saw_path = _partition_threads(
|
|
1350
|
+
threads, record.workspace_path
|
|
1351
|
+
)
|
|
1352
|
+
seen_ids = {
|
|
1353
|
+
entry.get("id")
|
|
1354
|
+
for entry in filtered
|
|
1355
|
+
if isinstance(entry.get("id"), str)
|
|
1356
|
+
}
|
|
1357
|
+
candidates = filtered + [
|
|
1358
|
+
entry
|
|
1359
|
+
for entry in unscoped_entries
|
|
1360
|
+
if entry.get("id") not in seen_ids
|
|
1361
|
+
]
|
|
1362
|
+
if not candidates and not local_thread_ids:
|
|
1363
|
+
if unscoped_entries and not saw_path:
|
|
1364
|
+
await self._send_message(
|
|
1365
|
+
message.chat_id,
|
|
1366
|
+
_with_conversation_id(
|
|
1367
|
+
"No workspace-tagged threads available. Use /resume --all to list "
|
|
1368
|
+
"unscoped threads.",
|
|
1369
|
+
chat_id=message.chat_id,
|
|
1370
|
+
thread_id=message.thread_id,
|
|
1371
|
+
),
|
|
1372
|
+
thread_id=message.thread_id,
|
|
1373
|
+
reply_to=message.message_id,
|
|
1374
|
+
)
|
|
1375
|
+
return None
|
|
1376
|
+
await self._send_message(
|
|
1377
|
+
message.chat_id,
|
|
1378
|
+
_with_conversation_id(
|
|
1379
|
+
"No previous threads found for this workspace. "
|
|
1380
|
+
"If threads exist, update the app-server to include cwd metadata or use /new.",
|
|
1381
|
+
chat_id=message.chat_id,
|
|
1382
|
+
thread_id=message.thread_id,
|
|
1383
|
+
),
|
|
1384
|
+
thread_id=message.thread_id,
|
|
1385
|
+
reply_to=message.message_id,
|
|
1386
|
+
)
|
|
1387
|
+
return None
|
|
1388
|
+
return ResumeThreadData(
|
|
1389
|
+
candidates=candidates,
|
|
1390
|
+
entries_by_id=entries_by_id,
|
|
1391
|
+
local_thread_ids=local_thread_ids,
|
|
1392
|
+
local_previews=local_previews,
|
|
1393
|
+
local_thread_topics=local_thread_topics,
|
|
1394
|
+
list_failed=list_failed,
|
|
1395
|
+
threads=threads,
|
|
1396
|
+
unscoped_entries=unscoped_entries,
|
|
1397
|
+
saw_path=saw_path,
|
|
1398
|
+
)
|
|
1399
|
+
|
|
1400
|
+
async def _render_resume_picker(
|
|
1401
|
+
self,
|
|
1402
|
+
message: TelegramMessage,
|
|
1403
|
+
record: "TelegramTopicRecord",
|
|
1404
|
+
key: str,
|
|
1405
|
+
args: ResumeCommandArgs,
|
|
1406
|
+
thread_data: ResumeThreadData,
|
|
1407
|
+
client: CodexAppServerClient,
|
|
1408
|
+
) -> None:
|
|
1409
|
+
"""Build and send the resume picker from gathered thread data."""
|
|
1410
|
+
entries_by_id = thread_data.entries_by_id
|
|
1411
|
+
local_thread_ids = thread_data.local_thread_ids
|
|
1412
|
+
local_previews = thread_data.local_previews
|
|
1413
|
+
local_thread_topics = thread_data.local_thread_topics
|
|
1414
|
+
missing_ids: list[str] = []
|
|
1415
|
+
if args.show_unscoped:
|
|
1416
|
+
for thread_id in local_thread_ids:
|
|
1417
|
+
if thread_id not in entries_by_id:
|
|
1418
|
+
missing_ids.append(thread_id)
|
|
1419
|
+
else:
|
|
1420
|
+
for thread_id in record.thread_ids:
|
|
1421
|
+
if thread_id not in entries_by_id:
|
|
1422
|
+
missing_ids.append(thread_id)
|
|
1423
|
+
if args.refresh and missing_ids:
|
|
1424
|
+
refreshed = await self._refresh_thread_summaries(
|
|
1425
|
+
client,
|
|
1426
|
+
missing_ids,
|
|
1427
|
+
topic_keys_by_thread=(
|
|
1428
|
+
local_thread_topics if args.show_unscoped else None
|
|
1429
|
+
),
|
|
1430
|
+
default_topic_key=key,
|
|
1431
|
+
)
|
|
1432
|
+
if refreshed:
|
|
1433
|
+
if args.show_unscoped:
|
|
1434
|
+
store_state = await self._store.load()
|
|
1435
|
+
(
|
|
1436
|
+
local_thread_ids,
|
|
1437
|
+
local_previews,
|
|
1438
|
+
local_thread_topics,
|
|
1439
|
+
) = _local_workspace_threads(
|
|
1440
|
+
store_state, record.workspace_path, current_key=key
|
|
1441
|
+
)
|
|
1442
|
+
for thread_id in record.thread_ids:
|
|
1443
|
+
local_thread_topics.setdefault(thread_id, set()).add(key)
|
|
1444
|
+
if thread_id not in local_thread_ids:
|
|
1445
|
+
local_thread_ids.append(thread_id)
|
|
1446
|
+
cached_preview = _thread_summary_preview(record, thread_id)
|
|
1447
|
+
if cached_preview:
|
|
1448
|
+
local_previews.setdefault(thread_id, cached_preview)
|
|
1449
|
+
else:
|
|
1450
|
+
record = await self._router.get_topic(key) or record
|
|
1451
|
+
items: list[tuple[str, str]] = []
|
|
1452
|
+
button_labels: dict[str, str] = {}
|
|
1453
|
+
seen_item_ids: set[str] = set()
|
|
1454
|
+
if args.show_unscoped:
|
|
1455
|
+
for entry in thread_data.candidates:
|
|
1456
|
+
candidate_id = entry.get("id")
|
|
1457
|
+
if not isinstance(candidate_id, str) or not candidate_id:
|
|
1458
|
+
continue
|
|
1459
|
+
if candidate_id in seen_item_ids:
|
|
1460
|
+
continue
|
|
1461
|
+
seen_item_ids.add(candidate_id)
|
|
1462
|
+
label = _format_thread_preview(entry)
|
|
1463
|
+
button_label = _extract_first_user_preview(entry)
|
|
1464
|
+
if button_label:
|
|
1465
|
+
button_labels[candidate_id] = button_label
|
|
1466
|
+
if label == "(no preview)":
|
|
1467
|
+
cached_preview = local_previews.get(candidate_id)
|
|
1468
|
+
if cached_preview:
|
|
1469
|
+
label = cached_preview
|
|
1470
|
+
items.append((candidate_id, label))
|
|
1471
|
+
for thread_id in local_thread_ids:
|
|
1472
|
+
if thread_id in seen_item_ids:
|
|
1473
|
+
continue
|
|
1474
|
+
seen_item_ids.add(thread_id)
|
|
1475
|
+
cached_preview = local_previews.get(thread_id)
|
|
1476
|
+
label = (
|
|
1477
|
+
cached_preview
|
|
1478
|
+
if cached_preview
|
|
1479
|
+
else _format_missing_thread_label(thread_id, None)
|
|
1480
|
+
)
|
|
1481
|
+
items.append((thread_id, label))
|
|
1482
|
+
else:
|
|
1483
|
+
if record.thread_ids:
|
|
1484
|
+
for thread_id in record.thread_ids:
|
|
1485
|
+
entry_data = entries_by_id.get(thread_id)
|
|
1486
|
+
if entry_data is None:
|
|
1487
|
+
cached_preview = _thread_summary_preview(record, thread_id)
|
|
1488
|
+
label = _format_missing_thread_label(thread_id, cached_preview)
|
|
1489
|
+
else:
|
|
1490
|
+
label = _format_thread_preview(entry_data)
|
|
1491
|
+
button_label = _extract_first_user_preview(entry_data)
|
|
1492
|
+
if button_label:
|
|
1493
|
+
button_labels[thread_id] = button_label
|
|
1494
|
+
if label == "(no preview)":
|
|
1495
|
+
cached_preview = _thread_summary_preview(record, thread_id)
|
|
1496
|
+
if cached_preview:
|
|
1497
|
+
label = cached_preview
|
|
1498
|
+
items.append((thread_id, label))
|
|
1499
|
+
else:
|
|
1500
|
+
for entry in entries_by_id.values():
|
|
1501
|
+
entry_id = entry.get("id")
|
|
1502
|
+
if not isinstance(entry_id, str) or not entry_id:
|
|
1503
|
+
continue
|
|
1504
|
+
label = _format_thread_preview(entry)
|
|
1505
|
+
button_label = _extract_first_user_preview(entry)
|
|
1506
|
+
if button_label:
|
|
1507
|
+
button_labels[entry_id] = button_label
|
|
1508
|
+
items.append((entry_id, label))
|
|
1509
|
+
if missing_ids:
|
|
1510
|
+
log_event(
|
|
1511
|
+
self._logger,
|
|
1512
|
+
logging.INFO,
|
|
1513
|
+
"telegram.resume.missing_thread_metadata",
|
|
1514
|
+
chat_id=message.chat_id,
|
|
1515
|
+
thread_id=message.thread_id,
|
|
1516
|
+
stored_count=len(record.thread_ids),
|
|
1517
|
+
listed_count=(
|
|
1518
|
+
len(entries_by_id)
|
|
1519
|
+
if not args.show_unscoped
|
|
1520
|
+
else len(thread_data.threads)
|
|
1521
|
+
),
|
|
1522
|
+
missing_ids=missing_ids[:RESUME_MISSING_IDS_LOG_LIMIT],
|
|
1523
|
+
list_failed=thread_data.list_failed,
|
|
1524
|
+
)
|
|
1525
|
+
if not items:
|
|
1526
|
+
await self._send_message(
|
|
1527
|
+
message.chat_id,
|
|
1528
|
+
_with_conversation_id(
|
|
1529
|
+
"No resumable threads found.",
|
|
1530
|
+
chat_id=message.chat_id,
|
|
1531
|
+
thread_id=message.thread_id,
|
|
1532
|
+
),
|
|
1533
|
+
thread_id=message.thread_id,
|
|
1534
|
+
reply_to=message.message_id,
|
|
1535
|
+
)
|
|
1536
|
+
return
|
|
1537
|
+
state = SelectionState(items=items, button_labels=button_labels)
|
|
1538
|
+
keyboard = self._build_resume_keyboard(state)
|
|
1539
|
+
self._resume_options[key] = state
|
|
1540
|
+
self._touch_cache_timestamp("resume_options", key)
|
|
1541
|
+
await self._send_message(
|
|
1542
|
+
message.chat_id,
|
|
1543
|
+
self._selection_prompt(RESUME_PICKER_PROMPT, state),
|
|
1544
|
+
thread_id=message.thread_id,
|
|
1545
|
+
reply_to=message.message_id,
|
|
1546
|
+
reply_markup=keyboard,
|
|
1547
|
+
)
|
|
1548
|
+
|
|
1549
|
+
async def _refresh_thread_summaries(
|
|
1550
|
+
self,
|
|
1551
|
+
client: CodexAppServerClient,
|
|
1552
|
+
thread_ids: Sequence[str],
|
|
1553
|
+
*,
|
|
1554
|
+
topic_keys_by_thread: Optional[dict[str, set[str]]] = None,
|
|
1555
|
+
default_topic_key: Optional[str] = None,
|
|
1556
|
+
) -> set[str]:
|
|
1557
|
+
refreshed: set[str] = set()
|
|
1558
|
+
if not thread_ids:
|
|
1559
|
+
return refreshed
|
|
1560
|
+
unique_ids: list[str] = []
|
|
1561
|
+
seen: set[str] = set()
|
|
1562
|
+
for thread_id in thread_ids:
|
|
1563
|
+
if not isinstance(thread_id, str) or not thread_id:
|
|
1564
|
+
continue
|
|
1565
|
+
if thread_id in seen:
|
|
1566
|
+
continue
|
|
1567
|
+
seen.add(thread_id)
|
|
1568
|
+
unique_ids.append(thread_id)
|
|
1569
|
+
if len(unique_ids) >= RESUME_REFRESH_LIMIT:
|
|
1570
|
+
break
|
|
1571
|
+
for thread_id in unique_ids:
|
|
1572
|
+
try:
|
|
1573
|
+
result = await client.thread_resume(thread_id)
|
|
1574
|
+
except Exception as exc:
|
|
1575
|
+
log_event(
|
|
1576
|
+
self._logger,
|
|
1577
|
+
logging.WARNING,
|
|
1578
|
+
"telegram.resume.refresh_failed",
|
|
1579
|
+
thread_id=thread_id,
|
|
1580
|
+
exc=exc,
|
|
1581
|
+
)
|
|
1582
|
+
continue
|
|
1583
|
+
user_preview, assistant_preview = _extract_thread_preview_parts(result)
|
|
1584
|
+
info = _extract_thread_info(result)
|
|
1585
|
+
workspace_path = info.get("workspace_path")
|
|
1586
|
+
rollout_path = info.get("rollout_path")
|
|
1587
|
+
if (
|
|
1588
|
+
user_preview is None
|
|
1589
|
+
and assistant_preview is None
|
|
1590
|
+
and workspace_path is None
|
|
1591
|
+
and rollout_path is None
|
|
1592
|
+
):
|
|
1593
|
+
continue
|
|
1594
|
+
last_used_at = now_iso() if user_preview or assistant_preview else None
|
|
1595
|
+
|
|
1596
|
+
def apply(
|
|
1597
|
+
record: TelegramTopicRecord,
|
|
1598
|
+
*,
|
|
1599
|
+
thread_id: str = thread_id,
|
|
1600
|
+
user_preview: Optional[str] = user_preview,
|
|
1601
|
+
assistant_preview: Optional[str] = assistant_preview,
|
|
1602
|
+
last_used_at: Optional[str] = last_used_at,
|
|
1603
|
+
workspace_path: Optional[str] = workspace_path,
|
|
1604
|
+
rollout_path: Optional[str] = rollout_path,
|
|
1605
|
+
) -> None:
|
|
1606
|
+
_set_thread_summary(
|
|
1607
|
+
record,
|
|
1608
|
+
thread_id,
|
|
1609
|
+
user_preview=user_preview,
|
|
1610
|
+
assistant_preview=assistant_preview,
|
|
1611
|
+
last_used_at=last_used_at,
|
|
1612
|
+
workspace_path=workspace_path,
|
|
1613
|
+
rollout_path=rollout_path,
|
|
1614
|
+
)
|
|
1615
|
+
|
|
1616
|
+
keys = (
|
|
1617
|
+
topic_keys_by_thread.get(thread_id)
|
|
1618
|
+
if topic_keys_by_thread is not None
|
|
1619
|
+
else None
|
|
1620
|
+
)
|
|
1621
|
+
if keys:
|
|
1622
|
+
for key in keys:
|
|
1623
|
+
await self._store.update_topic(key, apply)
|
|
1624
|
+
elif default_topic_key:
|
|
1625
|
+
await self._store.update_topic(default_topic_key, apply)
|
|
1626
|
+
else:
|
|
1627
|
+
continue
|
|
1628
|
+
refreshed.add(thread_id)
|
|
1629
|
+
return refreshed
|
|
1630
|
+
|
|
1631
|
+
async def _list_threads_paginated(
|
|
1632
|
+
self,
|
|
1633
|
+
client: CodexAppServerClient,
|
|
1634
|
+
*,
|
|
1635
|
+
limit: int,
|
|
1636
|
+
max_pages: int,
|
|
1637
|
+
needed_ids: Optional[set[str]] = None,
|
|
1638
|
+
) -> tuple[list[dict[str, Any]], set[str]]:
|
|
1639
|
+
entries: list[dict[str, Any]] = []
|
|
1640
|
+
found_ids: set[str] = set()
|
|
1641
|
+
seen_ids: set[str] = set()
|
|
1642
|
+
cursor: Optional[str] = None
|
|
1643
|
+
page_count = max(1, max_pages)
|
|
1644
|
+
for _ in range(page_count):
|
|
1645
|
+
payload = await client.thread_list(cursor=cursor, limit=limit)
|
|
1646
|
+
page_entries = _coerce_thread_list(payload)
|
|
1647
|
+
for entry in page_entries:
|
|
1648
|
+
if not isinstance(entry, dict):
|
|
1649
|
+
continue
|
|
1650
|
+
thread_id = entry.get("id")
|
|
1651
|
+
if isinstance(thread_id, str):
|
|
1652
|
+
if thread_id in seen_ids:
|
|
1653
|
+
continue
|
|
1654
|
+
seen_ids.add(thread_id)
|
|
1655
|
+
found_ids.add(thread_id)
|
|
1656
|
+
entries.append(entry)
|
|
1657
|
+
if needed_ids is not None and needed_ids.issubset(found_ids):
|
|
1658
|
+
break
|
|
1659
|
+
cursor = _extract_thread_list_cursor(payload)
|
|
1660
|
+
if not cursor:
|
|
1661
|
+
break
|
|
1662
|
+
return entries, found_ids
|
|
1663
|
+
|
|
1664
|
+
async def _resume_thread_by_id(
|
|
1665
|
+
self,
|
|
1666
|
+
key: str,
|
|
1667
|
+
thread_id: str,
|
|
1668
|
+
callback: Optional[TelegramCallbackQuery] = None,
|
|
1669
|
+
) -> None:
|
|
1670
|
+
chat_id, thread_id_val = _split_topic_key(key)
|
|
1671
|
+
self._resume_options.pop(key, None)
|
|
1672
|
+
record = await self._router.get_topic(key)
|
|
1673
|
+
if record is not None and self._effective_agent(record) == "opencode":
|
|
1674
|
+
await self._resume_opencode_thread_by_id(key, thread_id, callback=callback)
|
|
1675
|
+
return
|
|
1676
|
+
if record is None or not record.workspace_path:
|
|
1677
|
+
await self._answer_callback(callback, "Resume aborted")
|
|
1678
|
+
await self._finalize_selection(
|
|
1679
|
+
key,
|
|
1680
|
+
callback,
|
|
1681
|
+
_with_conversation_id(
|
|
1682
|
+
"Topic not bound; use /bind before resuming.",
|
|
1683
|
+
chat_id=chat_id,
|
|
1684
|
+
thread_id=thread_id_val,
|
|
1685
|
+
),
|
|
1686
|
+
)
|
|
1687
|
+
return
|
|
1688
|
+
try:
|
|
1689
|
+
client = await self._client_for_workspace(record.workspace_path)
|
|
1690
|
+
except AppServerUnavailableError as exc:
|
|
1691
|
+
log_event(
|
|
1692
|
+
self._logger,
|
|
1693
|
+
logging.WARNING,
|
|
1694
|
+
"telegram.app_server.unavailable",
|
|
1695
|
+
chat_id=chat_id,
|
|
1696
|
+
thread_id=thread_id_val,
|
|
1697
|
+
exc=exc,
|
|
1698
|
+
)
|
|
1699
|
+
await self._answer_callback(callback, "Resume aborted")
|
|
1700
|
+
await self._finalize_selection(
|
|
1701
|
+
key,
|
|
1702
|
+
callback,
|
|
1703
|
+
_with_conversation_id(
|
|
1704
|
+
"App server unavailable; try again or check logs.",
|
|
1705
|
+
chat_id=chat_id,
|
|
1706
|
+
thread_id=thread_id_val,
|
|
1707
|
+
),
|
|
1708
|
+
)
|
|
1709
|
+
return
|
|
1710
|
+
if client is None:
|
|
1711
|
+
await self._answer_callback(callback, "Resume aborted")
|
|
1712
|
+
await self._finalize_selection(
|
|
1713
|
+
key,
|
|
1714
|
+
callback,
|
|
1715
|
+
_with_conversation_id(
|
|
1716
|
+
"Topic not bound; use /bind before resuming.",
|
|
1717
|
+
chat_id=chat_id,
|
|
1718
|
+
thread_id=thread_id_val,
|
|
1719
|
+
),
|
|
1720
|
+
)
|
|
1721
|
+
return
|
|
1722
|
+
try:
|
|
1723
|
+
result = await client.thread_resume(thread_id)
|
|
1724
|
+
except Exception as exc:
|
|
1725
|
+
log_event(
|
|
1726
|
+
self._logger,
|
|
1727
|
+
logging.WARNING,
|
|
1728
|
+
"telegram.resume.failed",
|
|
1729
|
+
topic_key=key,
|
|
1730
|
+
thread_id=thread_id,
|
|
1731
|
+
exc=exc,
|
|
1732
|
+
)
|
|
1733
|
+
await self._answer_callback(callback, "Resume failed")
|
|
1734
|
+
chat_id, thread_id_val = _split_topic_key(key)
|
|
1735
|
+
await self._finalize_selection(
|
|
1736
|
+
key,
|
|
1737
|
+
callback,
|
|
1738
|
+
_with_conversation_id(
|
|
1739
|
+
"Failed to resume thread; check logs for details.",
|
|
1740
|
+
chat_id=chat_id,
|
|
1741
|
+
thread_id=thread_id_val,
|
|
1742
|
+
),
|
|
1743
|
+
)
|
|
1744
|
+
return
|
|
1745
|
+
info = _extract_thread_info(result)
|
|
1746
|
+
resumed_path = info.get("workspace_path")
|
|
1747
|
+
if record is None or not record.workspace_path:
|
|
1748
|
+
await self._answer_callback(callback, "Resume aborted")
|
|
1749
|
+
await self._finalize_selection(
|
|
1750
|
+
key,
|
|
1751
|
+
callback,
|
|
1752
|
+
_with_conversation_id(
|
|
1753
|
+
"Topic not bound; use /bind before resuming.",
|
|
1754
|
+
chat_id=chat_id,
|
|
1755
|
+
thread_id=thread_id_val,
|
|
1756
|
+
),
|
|
1757
|
+
)
|
|
1758
|
+
return
|
|
1759
|
+
if not isinstance(resumed_path, str):
|
|
1760
|
+
await self._answer_callback(callback, "Resume aborted")
|
|
1761
|
+
await self._finalize_selection(
|
|
1762
|
+
key,
|
|
1763
|
+
callback,
|
|
1764
|
+
_with_conversation_id(
|
|
1765
|
+
"Thread metadata missing workspace path; resume aborted to avoid cross-worktree mixups.",
|
|
1766
|
+
chat_id=chat_id,
|
|
1767
|
+
thread_id=thread_id_val,
|
|
1768
|
+
),
|
|
1769
|
+
)
|
|
1770
|
+
return
|
|
1771
|
+
try:
|
|
1772
|
+
workspace_root = Path(record.workspace_path).expanduser().resolve()
|
|
1773
|
+
resumed_root = Path(resumed_path).expanduser().resolve()
|
|
1774
|
+
except Exception:
|
|
1775
|
+
await self._answer_callback(callback, "Resume aborted")
|
|
1776
|
+
await self._finalize_selection(
|
|
1777
|
+
key,
|
|
1778
|
+
callback,
|
|
1779
|
+
_with_conversation_id(
|
|
1780
|
+
"Thread workspace path is invalid; resume aborted.",
|
|
1781
|
+
chat_id=chat_id,
|
|
1782
|
+
thread_id=thread_id_val,
|
|
1783
|
+
),
|
|
1784
|
+
)
|
|
1785
|
+
return
|
|
1786
|
+
if not _paths_compatible(workspace_root, resumed_root):
|
|
1787
|
+
await self._answer_callback(callback, "Resume aborted")
|
|
1788
|
+
await self._finalize_selection(
|
|
1789
|
+
key,
|
|
1790
|
+
callback,
|
|
1791
|
+
_with_conversation_id(
|
|
1792
|
+
"Thread belongs to a different workspace; resume aborted.",
|
|
1793
|
+
chat_id=chat_id,
|
|
1794
|
+
thread_id=thread_id_val,
|
|
1795
|
+
),
|
|
1796
|
+
)
|
|
1797
|
+
return
|
|
1798
|
+
conflict_key = await self._find_thread_conflict(thread_id, key=key)
|
|
1799
|
+
if conflict_key:
|
|
1800
|
+
await self._answer_callback(callback, "Resume aborted")
|
|
1801
|
+
await self._finalize_selection(
|
|
1802
|
+
key,
|
|
1803
|
+
callback,
|
|
1804
|
+
_with_conversation_id(
|
|
1805
|
+
"Thread is already active in another topic; resume aborted.",
|
|
1806
|
+
chat_id=chat_id,
|
|
1807
|
+
thread_id=thread_id_val,
|
|
1808
|
+
),
|
|
1809
|
+
)
|
|
1810
|
+
log_event(
|
|
1811
|
+
self._logger,
|
|
1812
|
+
logging.WARNING,
|
|
1813
|
+
"telegram.resume.conflict",
|
|
1814
|
+
topic_key=key,
|
|
1815
|
+
thread_id=thread_id,
|
|
1816
|
+
conflict_topic=conflict_key,
|
|
1817
|
+
)
|
|
1818
|
+
return
|
|
1819
|
+
updated_record = await self._apply_thread_result(
|
|
1820
|
+
chat_id,
|
|
1821
|
+
thread_id_val,
|
|
1822
|
+
result,
|
|
1823
|
+
active_thread_id=thread_id,
|
|
1824
|
+
overwrite_defaults=True,
|
|
1825
|
+
)
|
|
1826
|
+
await self._answer_callback(callback, "Resumed thread")
|
|
1827
|
+
message = _format_resume_summary(
|
|
1828
|
+
thread_id,
|
|
1829
|
+
result,
|
|
1830
|
+
workspace_path=updated_record.workspace_path,
|
|
1831
|
+
model=updated_record.model,
|
|
1832
|
+
effort=updated_record.effort,
|
|
1833
|
+
)
|
|
1834
|
+
await self._finalize_selection(key, callback, message)
|
|
1835
|
+
|
|
1836
|
+
async def _resume_opencode_thread_by_id(
|
|
1837
|
+
self,
|
|
1838
|
+
key: str,
|
|
1839
|
+
thread_id: str,
|
|
1840
|
+
callback: Optional[TelegramCallbackQuery] = None,
|
|
1841
|
+
) -> None:
|
|
1842
|
+
chat_id, thread_id_val = _split_topic_key(key)
|
|
1843
|
+
self._resume_options.pop(key, None)
|
|
1844
|
+
record = await self._router.get_topic(key)
|
|
1845
|
+
if record is None or not record.workspace_path:
|
|
1846
|
+
await self._answer_callback(callback, "Resume aborted")
|
|
1847
|
+
await self._finalize_selection(
|
|
1848
|
+
key,
|
|
1849
|
+
callback,
|
|
1850
|
+
_with_conversation_id(
|
|
1851
|
+
"Topic not bound; use /bind before resuming.",
|
|
1852
|
+
chat_id=chat_id,
|
|
1853
|
+
thread_id=thread_id_val,
|
|
1854
|
+
),
|
|
1855
|
+
)
|
|
1856
|
+
return
|
|
1857
|
+
supervisor = getattr(self, "_opencode_supervisor", None)
|
|
1858
|
+
if supervisor is None:
|
|
1859
|
+
await self._answer_callback(callback, "Resume aborted")
|
|
1860
|
+
await self._finalize_selection(
|
|
1861
|
+
key,
|
|
1862
|
+
callback,
|
|
1863
|
+
_with_conversation_id(
|
|
1864
|
+
"OpenCode backend unavailable; install opencode or switch to /agent codex.",
|
|
1865
|
+
chat_id=chat_id,
|
|
1866
|
+
thread_id=thread_id_val,
|
|
1867
|
+
),
|
|
1868
|
+
)
|
|
1869
|
+
return
|
|
1870
|
+
workspace_root = self._canonical_workspace_root(record.workspace_path)
|
|
1871
|
+
if workspace_root is None:
|
|
1872
|
+
await self._answer_callback(callback, "Resume aborted")
|
|
1873
|
+
await self._finalize_selection(
|
|
1874
|
+
key,
|
|
1875
|
+
callback,
|
|
1876
|
+
_with_conversation_id(
|
|
1877
|
+
"Workspace unavailable; resume aborted.",
|
|
1878
|
+
chat_id=chat_id,
|
|
1879
|
+
thread_id=thread_id_val,
|
|
1880
|
+
),
|
|
1881
|
+
)
|
|
1882
|
+
return
|
|
1883
|
+
try:
|
|
1884
|
+
client = await supervisor.get_client(workspace_root)
|
|
1885
|
+
session = await client.get_session(thread_id)
|
|
1886
|
+
except Exception as exc:
|
|
1887
|
+
log_event(
|
|
1888
|
+
self._logger,
|
|
1889
|
+
logging.WARNING,
|
|
1890
|
+
"telegram.opencode.resume.failed",
|
|
1891
|
+
topic_key=key,
|
|
1892
|
+
thread_id=thread_id,
|
|
1893
|
+
exc=exc,
|
|
1894
|
+
)
|
|
1895
|
+
await self._answer_callback(callback, "Resume failed")
|
|
1896
|
+
await self._finalize_selection(
|
|
1897
|
+
key,
|
|
1898
|
+
callback,
|
|
1899
|
+
_with_conversation_id(
|
|
1900
|
+
"Failed to resume OpenCode thread; check logs for details.",
|
|
1901
|
+
chat_id=chat_id,
|
|
1902
|
+
thread_id=thread_id_val,
|
|
1903
|
+
),
|
|
1904
|
+
)
|
|
1905
|
+
return
|
|
1906
|
+
resumed_path = _extract_opencode_session_path(session)
|
|
1907
|
+
if resumed_path:
|
|
1908
|
+
try:
|
|
1909
|
+
workspace_root = Path(record.workspace_path).expanduser().resolve()
|
|
1910
|
+
resumed_root = Path(resumed_path).expanduser().resolve()
|
|
1911
|
+
except Exception:
|
|
1912
|
+
await self._answer_callback(callback, "Resume aborted")
|
|
1913
|
+
await self._finalize_selection(
|
|
1914
|
+
key,
|
|
1915
|
+
callback,
|
|
1916
|
+
_with_conversation_id(
|
|
1917
|
+
"Thread workspace path is invalid; resume aborted.",
|
|
1918
|
+
chat_id=chat_id,
|
|
1919
|
+
thread_id=thread_id_val,
|
|
1920
|
+
),
|
|
1921
|
+
)
|
|
1922
|
+
return
|
|
1923
|
+
if not _paths_compatible(workspace_root, resumed_root):
|
|
1924
|
+
await self._answer_callback(callback, "Resume aborted")
|
|
1925
|
+
await self._finalize_selection(
|
|
1926
|
+
key,
|
|
1927
|
+
callback,
|
|
1928
|
+
_with_conversation_id(
|
|
1929
|
+
"Thread belongs to a different workspace; resume aborted.",
|
|
1930
|
+
chat_id=chat_id,
|
|
1931
|
+
thread_id=thread_id_val,
|
|
1932
|
+
),
|
|
1933
|
+
)
|
|
1934
|
+
return
|
|
1935
|
+
conflict_key = await self._find_thread_conflict(thread_id, key=key)
|
|
1936
|
+
if conflict_key:
|
|
1937
|
+
await self._answer_callback(callback, "Resume aborted")
|
|
1938
|
+
await self._finalize_selection(
|
|
1939
|
+
key,
|
|
1940
|
+
callback,
|
|
1941
|
+
_with_conversation_id(
|
|
1942
|
+
"Thread is already active in another topic; resume aborted.",
|
|
1943
|
+
chat_id=chat_id,
|
|
1944
|
+
thread_id=thread_id_val,
|
|
1945
|
+
),
|
|
1946
|
+
)
|
|
1947
|
+
log_event(
|
|
1948
|
+
self._logger,
|
|
1949
|
+
logging.WARNING,
|
|
1950
|
+
"telegram.resume.conflict",
|
|
1951
|
+
topic_key=key,
|
|
1952
|
+
thread_id=thread_id,
|
|
1953
|
+
conflict_topic=conflict_key,
|
|
1954
|
+
)
|
|
1955
|
+
return
|
|
1956
|
+
|
|
1957
|
+
def apply(record: "TelegramTopicRecord") -> None:
|
|
1958
|
+
record.active_thread_id = thread_id
|
|
1959
|
+
if thread_id in record.thread_ids:
|
|
1960
|
+
record.thread_ids.remove(thread_id)
|
|
1961
|
+
record.thread_ids.insert(0, thread_id)
|
|
1962
|
+
if len(record.thread_ids) > MAX_TOPIC_THREAD_HISTORY:
|
|
1963
|
+
record.thread_ids = record.thread_ids[:MAX_TOPIC_THREAD_HISTORY]
|
|
1964
|
+
_set_thread_summary(
|
|
1965
|
+
record,
|
|
1966
|
+
thread_id,
|
|
1967
|
+
last_used_at=now_iso(),
|
|
1968
|
+
workspace_path=record.workspace_path,
|
|
1969
|
+
rollout_path=record.rollout_path,
|
|
1970
|
+
)
|
|
1971
|
+
|
|
1972
|
+
updated_record = await self._router.update_topic(chat_id, thread_id_val, apply)
|
|
1973
|
+
await self._answer_callback(callback, "Resumed thread")
|
|
1974
|
+
summary = None
|
|
1975
|
+
if updated_record is not None:
|
|
1976
|
+
summary = updated_record.thread_summaries.get(thread_id)
|
|
1977
|
+
entry: dict[str, Any] = {}
|
|
1978
|
+
if summary is not None:
|
|
1979
|
+
entry = {
|
|
1980
|
+
"user_preview": summary.user_preview,
|
|
1981
|
+
"assistant_preview": summary.assistant_preview,
|
|
1982
|
+
}
|
|
1983
|
+
message = _format_resume_summary(
|
|
1984
|
+
thread_id,
|
|
1985
|
+
entry,
|
|
1986
|
+
workspace_path=updated_record.workspace_path if updated_record else None,
|
|
1987
|
+
model=updated_record.model if updated_record else None,
|
|
1988
|
+
effort=updated_record.effort if updated_record else None,
|
|
1989
|
+
)
|
|
1990
|
+
await self._finalize_selection(key, callback, message)
|
|
1991
|
+
|
|
1992
|
+
async def _handle_status(
|
|
1993
|
+
self, message: TelegramMessage, _args: str = "", runtime: Optional[Any] = None
|
|
1994
|
+
) -> None:
|
|
1995
|
+
key = await self._resolve_topic_key(message.chat_id, message.thread_id)
|
|
1996
|
+
record = await self._router.ensure_topic(message.chat_id, message.thread_id)
|
|
1997
|
+
await self._refresh_workspace_id(key, record)
|
|
1998
|
+
if runtime is None:
|
|
1999
|
+
runtime = self._router.runtime_for(key)
|
|
2000
|
+
approval_policy, sandbox_policy = self._effective_policies(record)
|
|
2001
|
+
agent = self._effective_agent(record)
|
|
2002
|
+
effort_label = (
|
|
2003
|
+
record.effort or "default" if self._agent_supports_effort(agent) else "n/a"
|
|
2004
|
+
)
|
|
2005
|
+
lines = [
|
|
2006
|
+
f"Workspace: {record.workspace_path or 'unbound'}",
|
|
2007
|
+
f"Workspace ID: {record.workspace_id or 'unknown'}",
|
|
2008
|
+
f"Active thread: {record.active_thread_id or 'none'}",
|
|
2009
|
+
f"Active turn: {runtime.current_turn_id or 'none'}",
|
|
2010
|
+
f"Agent: {agent}",
|
|
2011
|
+
f"Resume: {'supported' if self._agent_supports_resume(agent) else 'unsupported'}",
|
|
2012
|
+
f"Model: {record.model or 'default'}",
|
|
2013
|
+
f"Effort: {effort_label}",
|
|
2014
|
+
f"Approval mode: {record.approval_mode}",
|
|
2015
|
+
f"Approval policy: {approval_policy or 'default'}",
|
|
2016
|
+
f"Sandbox policy: {_format_sandbox_policy(sandbox_policy)}",
|
|
2017
|
+
]
|
|
2018
|
+
pending = await self._store.pending_approvals_for_key(key)
|
|
2019
|
+
if pending:
|
|
2020
|
+
lines.append(f"Pending approvals: {len(pending)}")
|
|
2021
|
+
if len(pending) == 1:
|
|
2022
|
+
age = _approval_age_seconds(pending[0].created_at)
|
|
2023
|
+
age_label = f"{age}s" if isinstance(age, int) else "unknown age"
|
|
2024
|
+
lines.append(f"Pending request: {pending[0].request_id} ({age_label})")
|
|
2025
|
+
else:
|
|
2026
|
+
preview = ", ".join(item.request_id for item in pending[:3])
|
|
2027
|
+
suffix = "" if len(pending) <= 3 else "..."
|
|
2028
|
+
lines.append(f"Pending requests: {preview}{suffix}")
|
|
2029
|
+
if record.summary:
|
|
2030
|
+
lines.append(f"Summary: {record.summary}")
|
|
2031
|
+
if record.active_thread_id:
|
|
2032
|
+
token_usage = self._token_usage_by_thread.get(record.active_thread_id)
|
|
2033
|
+
lines.extend(_format_token_usage(token_usage))
|
|
2034
|
+
rate_limits = await self._read_rate_limits(record.workspace_path, agent=agent)
|
|
2035
|
+
lines.extend(_format_rate_limits(rate_limits))
|
|
2036
|
+
if not record.workspace_path:
|
|
2037
|
+
lines.append("Use /bind <repo_id> or /bind <path>.")
|
|
2038
|
+
await self._send_message(
|
|
2039
|
+
message.chat_id,
|
|
2040
|
+
"\n".join(lines),
|
|
2041
|
+
thread_id=message.thread_id,
|
|
2042
|
+
reply_to=message.message_id,
|
|
2043
|
+
)
|