opencode-hermes-mcp 0.4.1__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.
- opencode_hermes_mcp/__init__.py +6 -0
- opencode_hermes_mcp/client.py +288 -0
- opencode_hermes_mcp/controller.py +896 -0
- opencode_hermes_mcp/installer.py +1012 -0
- opencode_hermes_mcp/models.py +234 -0
- opencode_hermes_mcp/pin.txt +1 -0
- opencode_hermes_mcp/server.py +259 -0
- opencode_hermes_mcp/smoke_client.py +87 -0
- opencode_hermes_mcp-0.4.1.dist-info/METADATA +289 -0
- opencode_hermes_mcp-0.4.1.dist-info/RECORD +14 -0
- opencode_hermes_mcp-0.4.1.dist-info/WHEEL +5 -0
- opencode_hermes_mcp-0.4.1.dist-info/entry_points.txt +2 -0
- opencode_hermes_mcp-0.4.1.dist-info/licenses/LICENSE +21 -0
- opencode_hermes_mcp-0.4.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,896 @@
|
|
|
1
|
+
"""Deterministic controller between Hermes (MCP) and the OpenCode server.
|
|
2
|
+
|
|
3
|
+
No LLM, no inference. Responsibilities:
|
|
4
|
+
* keep one SSE subscription per active wait (scoped to the turn's directory);
|
|
5
|
+
* reconcile against REST (/session/status, /question, /permission, messages)
|
|
6
|
+
on every SSE poke AND on a bounded safety timeout (SSE can drop);
|
|
7
|
+
* block until the turn ends: completed / error / needs_agent_input / timeout;
|
|
8
|
+
* persist a small durable turn state so that answering a question or
|
|
9
|
+
permission works even after the controller process restarts.
|
|
10
|
+
|
|
11
|
+
Completion semantics (verified against live server 1.18.21 — see skill
|
|
12
|
+
reference mcp-controller.md):
|
|
13
|
+
* /session/status (scoped) lists only ACTIVE sessions; absence = idle;
|
|
14
|
+
* `session.idle` fires BETWEEN assistant messages too — NOT terminal;
|
|
15
|
+
* complete = root+descendants not busy AND last assistant message has a
|
|
16
|
+
terminal `finish` (anything but 'tool-calls') AND postdates submission.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import os
|
|
22
|
+
import time
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from .client import OpenCode, OpenCodeError, event_stream
|
|
26
|
+
from .models import (
|
|
27
|
+
assistant_text,
|
|
28
|
+
clear_turn_state,
|
|
29
|
+
diff_summary,
|
|
30
|
+
last_assistant,
|
|
31
|
+
last_user_message,
|
|
32
|
+
load_turn_state,
|
|
33
|
+
msg_created_ms,
|
|
34
|
+
new_turn,
|
|
35
|
+
parse_model,
|
|
36
|
+
save_turn_state,
|
|
37
|
+
validate_agent,
|
|
38
|
+
validate_permission_reply,
|
|
39
|
+
validate_question_answers,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Safety: max seconds to wait on the SSE poke before forcing a REST reconcile.
|
|
43
|
+
RECONCILE_POKE_TIMEOUT = 30.0
|
|
44
|
+
# Max seconds a single blocking tool call may wait (Hermes mcp timeout >= this).
|
|
45
|
+
DEFAULT_RUN_TIMEOUT = 3600.0
|
|
46
|
+
# After we answer a question / decide a permission, if the session goes idle
|
|
47
|
+
# and makes NO further progress (no busy period, no new assistant message) for
|
|
48
|
+
# this long, the turn has been terminated by the interaction resolution — most
|
|
49
|
+
# notably a permission REJECT, where OpenCode 1.18.21 ends the turn with the
|
|
50
|
+
# last assistant message still at finish='tool-calls' and no error. Without
|
|
51
|
+
# this, the completion heuristic (finish != tool-calls) would wait forever.
|
|
52
|
+
INTERACTION_STALL_GRACE_MS = 45_000
|
|
53
|
+
|
|
54
|
+
SUPERVISOR_GUARD = """\
|
|
55
|
+
[Hermes supervision constraint]
|
|
56
|
+
You are the coding worker supervised by Hermes.
|
|
57
|
+
You may use your configured OpenCode subagents when useful, but run delegated
|
|
58
|
+
subagents strictly sequentially: one at a time, wait for each to finish before
|
|
59
|
+
starting the next, and never launch multiple subagents concurrently.
|
|
60
|
+
[/Hermes supervision constraint]"""
|
|
61
|
+
|
|
62
|
+
# Threshold for "anecdotal" assistant text on finish='length': a message
|
|
63
|
+
# shorter than this (after stripping) is NOT a deliverable — the model was
|
|
64
|
+
# cut off mid-preamble (e.g. a truncated "Let me analyze..."). 200 chars is
|
|
65
|
+
# far below any real answer yet comfortably above a fragment.
|
|
66
|
+
ANECDOTAL_TEXT_MAX_CHARS = 200
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def classify_length_finish(diff_summary: dict[str, Any], text: str) -> str:
|
|
70
|
+
"""Classify a finish='length' completion: "completed" or "error".
|
|
71
|
+
|
|
72
|
+
"completed" when a deliverable exists (non-empty diff OR substantive
|
|
73
|
+
assistant text); "error" (output_limit_reached) when the model exhausted
|
|
74
|
+
its output budget with neither — the supervisor must then send a short
|
|
75
|
+
corrective turn on the SAME session.
|
|
76
|
+
"""
|
|
77
|
+
has_diff = int(diff_summary.get("files") or 0) > 0 or bool(diff_summary.get("changed_files"))
|
|
78
|
+
has_text = len((text or "").strip()) >= ANECDOTAL_TEXT_MAX_CHARS
|
|
79
|
+
return "completed" if (has_diff or has_text) else "error"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Controller:
|
|
83
|
+
def __init__(self, oc: OpenCode) -> None:
|
|
84
|
+
self.oc = oc
|
|
85
|
+
self._lock = asyncio.Lock()
|
|
86
|
+
self._active: dict[str, Any] | None = None # turn currently being waited on
|
|
87
|
+
self._pokes: dict[str, asyncio.Event] = {}
|
|
88
|
+
self._aborted: set[str] = set() # session ids we asked to abort
|
|
89
|
+
self._sse_connected = False
|
|
90
|
+
self._sse_reconnects = 0
|
|
91
|
+
|
|
92
|
+
# ------------------------------------------------------------------ #
|
|
93
|
+
# SSE lifecycle
|
|
94
|
+
# ------------------------------------------------------------------ #
|
|
95
|
+
|
|
96
|
+
def _poke(self, sid: str) -> asyncio.Event:
|
|
97
|
+
ev = self._pokes.get(sid)
|
|
98
|
+
if ev is None:
|
|
99
|
+
ev = asyncio.Event()
|
|
100
|
+
self._pokes[sid] = ev
|
|
101
|
+
return ev
|
|
102
|
+
|
|
103
|
+
def _poke_set(self, sid: str) -> None:
|
|
104
|
+
self._poke(sid).set()
|
|
105
|
+
|
|
106
|
+
async def _subscribe(self, turn: dict[str, Any]) -> None:
|
|
107
|
+
"""SSE subscription scoped to the turn's directory (fast path).
|
|
108
|
+
|
|
109
|
+
CRITICAL: the stream must be scoped with ?directory — unscoped it
|
|
110
|
+
only carries server.heartbeat, never session events. If the
|
|
111
|
+
connection drops, the turn keeps working: the safety poke in the
|
|
112
|
+
wait loop forces REST reconciliation every RECONCILE_POKE_TIMEOUT,
|
|
113
|
+
and the first reconcile after reconnect re-checks pending
|
|
114
|
+
questions/permissions (they are REST-persistent, so one that
|
|
115
|
+
appeared during the drop is caught).
|
|
116
|
+
"""
|
|
117
|
+
backoff = 1.0
|
|
118
|
+
while True:
|
|
119
|
+
try:
|
|
120
|
+
async for event in event_stream(await self.oc.client(), turn["directory"]):
|
|
121
|
+
self._sse_connected = True
|
|
122
|
+
backoff = 1.0
|
|
123
|
+
self._handle_event(event)
|
|
124
|
+
except asyncio.CancelledError:
|
|
125
|
+
raise
|
|
126
|
+
except Exception: # noqa: BLE001 — reconnect on any SSE error
|
|
127
|
+
self._sse_connected = False
|
|
128
|
+
self._sse_reconnects += 1
|
|
129
|
+
# Nudge the turn so it reconciles via REST meanwhile.
|
|
130
|
+
if self._active is turn:
|
|
131
|
+
self._poke_set(turn["session_id"])
|
|
132
|
+
await asyncio.sleep(backoff)
|
|
133
|
+
backoff = min(backoff * 2, 30.0)
|
|
134
|
+
|
|
135
|
+
def _handle_event(self, event: dict[str, Any]) -> None:
|
|
136
|
+
etype = event.get("type", "")
|
|
137
|
+
props = event.get("properties", {}) or {}
|
|
138
|
+
sid = props.get("sessionID")
|
|
139
|
+
if not sid:
|
|
140
|
+
return
|
|
141
|
+
active = self._active
|
|
142
|
+
if not active:
|
|
143
|
+
return
|
|
144
|
+
root = active["session_id"]
|
|
145
|
+
tree: set[str] = active["tree"]
|
|
146
|
+
# Relevant only if the event is for the root or a known descendant.
|
|
147
|
+
if sid != root and sid not in tree:
|
|
148
|
+
return
|
|
149
|
+
# Grow the tree when a child session is created under the root.
|
|
150
|
+
if etype == "session.created":
|
|
151
|
+
tree.add(sid)
|
|
152
|
+
# Any of these means "something changed; reconcile".
|
|
153
|
+
# NOTE: session.idle is NOT terminal by itself (it fires between
|
|
154
|
+
# assistant messages); reconciliation decides via the finish reason.
|
|
155
|
+
if etype in {
|
|
156
|
+
"session.status",
|
|
157
|
+
"session.idle",
|
|
158
|
+
"session.error",
|
|
159
|
+
"question.asked",
|
|
160
|
+
"permission.asked",
|
|
161
|
+
"session.created",
|
|
162
|
+
"message.updated",
|
|
163
|
+
"session.diff",
|
|
164
|
+
}:
|
|
165
|
+
self._poke_set(root)
|
|
166
|
+
|
|
167
|
+
# ------------------------------------------------------------------ #
|
|
168
|
+
# Session tree
|
|
169
|
+
# ------------------------------------------------------------------ #
|
|
170
|
+
|
|
171
|
+
async def _refresh_tree(self, root: str, tree: set[str]) -> set[str]:
|
|
172
|
+
"""Expand the session tree (root + descendants) via REST."""
|
|
173
|
+
queue = [root]
|
|
174
|
+
seen = {root}
|
|
175
|
+
while queue:
|
|
176
|
+
cur = queue.pop(0)
|
|
177
|
+
for child in await self.oc.children(cur):
|
|
178
|
+
cid = child.get("id")
|
|
179
|
+
if cid and cid not in seen:
|
|
180
|
+
seen.add(cid)
|
|
181
|
+
queue.append(cid)
|
|
182
|
+
tree.update(seen)
|
|
183
|
+
return tree
|
|
184
|
+
|
|
185
|
+
# ------------------------------------------------------------------ #
|
|
186
|
+
# Reconciliation (the real work, via REST)
|
|
187
|
+
# ------------------------------------------------------------------ #
|
|
188
|
+
|
|
189
|
+
async def _reconcile(self, turn: dict[str, Any]) -> dict[str, Any]:
|
|
190
|
+
root = turn["session_id"]
|
|
191
|
+
directory = turn["directory"]
|
|
192
|
+
tree = await self._refresh_tree(root, turn["tree"])
|
|
193
|
+
statuses = await self.oc.status_map(directory)
|
|
194
|
+
|
|
195
|
+
perms = [p for p in await self.oc.permissions(directory) if p.get("sessionID") in tree]
|
|
196
|
+
quests = [q for q in await self.oc.questions(directory) if q.get("sessionID") in tree]
|
|
197
|
+
|
|
198
|
+
# 1) needs-input takes priority
|
|
199
|
+
# (skip requests we just answered — the server may list them briefly
|
|
200
|
+
# until the agent's turn consumes them)
|
|
201
|
+
ignore: set[str] = turn.get("ignore_ids") or set()
|
|
202
|
+
quests = [q for q in quests if q.get("id") not in ignore]
|
|
203
|
+
perms = [p for p in perms if p.get("id") not in ignore]
|
|
204
|
+
if quests:
|
|
205
|
+
return {
|
|
206
|
+
"state": "needs_agent_input",
|
|
207
|
+
"kind": "question",
|
|
208
|
+
"question": quests[0],
|
|
209
|
+
"session_id": root,
|
|
210
|
+
"tree": sorted(tree),
|
|
211
|
+
"submitted_at_ms": turn["submitted_at_ms"],
|
|
212
|
+
}
|
|
213
|
+
if perms:
|
|
214
|
+
return {
|
|
215
|
+
"state": "needs_agent_input",
|
|
216
|
+
"kind": "permission",
|
|
217
|
+
"permission": perms[0],
|
|
218
|
+
"session_id": root,
|
|
219
|
+
"tree": sorted(tree),
|
|
220
|
+
"submitted_at_ms": turn["submitted_at_ms"],
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
# 2) still working? (root or any descendant busy/retry)
|
|
224
|
+
busy = any(
|
|
225
|
+
(statuses.get(c) or {}).get("type") in {"busy", "retry"} for c in tree
|
|
226
|
+
)
|
|
227
|
+
now_ms = int(time.time() * 1000)
|
|
228
|
+
|
|
229
|
+
# 2a) we asked to abort this session → report aborted promptly
|
|
230
|
+
# (don't wait for the server to settle or the run timeout).
|
|
231
|
+
if root in self._aborted:
|
|
232
|
+
self._aborted.discard(root)
|
|
233
|
+
return {
|
|
234
|
+
"state": "aborted",
|
|
235
|
+
"session_id": root,
|
|
236
|
+
"agent": turn.get("agent"),
|
|
237
|
+
"tree": sorted(tree),
|
|
238
|
+
"note": "abort requested via opencode_abort",
|
|
239
|
+
}
|
|
240
|
+
if busy:
|
|
241
|
+
# Progress is happening: reset the idle clock (the post-interaction
|
|
242
|
+
# stall check below requires continuous idle for the grace window).
|
|
243
|
+
turn["idle_since_ms"] = None
|
|
244
|
+
return {"state": "working", "tree": sorted(tree)}
|
|
245
|
+
# Not busy: start/keep the idle clock (used for post-interaction
|
|
246
|
+
# stall detection below).
|
|
247
|
+
if turn.get("idle_since_ms") is None:
|
|
248
|
+
turn["idle_since_ms"] = now_ms
|
|
249
|
+
|
|
250
|
+
# 3) not busy → is the turn actually finished, or just between steps?
|
|
251
|
+
messages = await self.oc.messages(root, directory)
|
|
252
|
+
last = last_assistant(messages)
|
|
253
|
+
n_assistant = sum(
|
|
254
|
+
1 for m in messages if (m.get("info") or {}).get("role") == "assistant"
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
# 3a) Post-interaction stall: we answered a question / decided a
|
|
258
|
+
# permission, the session has been continuously idle for the grace
|
|
259
|
+
# window, no new assistant message was created (count unchanged), the
|
|
260
|
+
# last assistant message is a non-terminal tool-calls tail, and there
|
|
261
|
+
# is no error. That is the signature of a turn TERMINATED by the
|
|
262
|
+
# interaction resolution — most notably a permission REJECT, where
|
|
263
|
+
# OpenCode 1.18.21 ends the turn leaving the last assistant message at
|
|
264
|
+
# finish='tool-calls' with no error and no new message. Without this,
|
|
265
|
+
# the completion heuristic (finish != tool-calls) would wait forever.
|
|
266
|
+
#
|
|
267
|
+
# Why count-based (not a "resumed" boolean): a transient 'busy' read
|
|
268
|
+
# right after posting the reply would wrongly clear a boolean flag.
|
|
269
|
+
# The count is authoritative: a resumed model creates a new assistant
|
|
270
|
+
# message (count grows → this check is skipped) or updates the finish
|
|
271
|
+
# to a terminal reason (caught by the normal heuristic below).
|
|
272
|
+
count_at_answer = turn.get("assistant_count_at_answer")
|
|
273
|
+
if (
|
|
274
|
+
turn.get("answered_interaction")
|
|
275
|
+
and count_at_answer is not None
|
|
276
|
+
and n_assistant <= count_at_answer
|
|
277
|
+
and last is not None
|
|
278
|
+
and (last.get("info") or {}).get("finish") in ("tool-calls", None)
|
|
279
|
+
and not (last.get("info") or {}).get("error")
|
|
280
|
+
and turn.get("idle_since_ms") is not None
|
|
281
|
+
and now_ms - turn["idle_since_ms"] >= INTERACTION_STALL_GRACE_MS
|
|
282
|
+
):
|
|
283
|
+
user_msg = last_user_message(messages)
|
|
284
|
+
diffs = await self.oc.diff(
|
|
285
|
+
root, directory, message_id=(user_msg or {}).get("info", {}).get("id")
|
|
286
|
+
)
|
|
287
|
+
return {
|
|
288
|
+
"state": "completed",
|
|
289
|
+
"session_id": root,
|
|
290
|
+
"agent": turn.get("agent"),
|
|
291
|
+
"last_assistant_text": assistant_text(last),
|
|
292
|
+
"diff": diff_summary(diffs),
|
|
293
|
+
"tree": sorted(tree),
|
|
294
|
+
"timing": {
|
|
295
|
+
"submitted_at_ms": turn["submitted_at_ms"],
|
|
296
|
+
"completed_at_ms": now_ms,
|
|
297
|
+
"elapsed_ms": now_ms - turn["submitted_at_ms"],
|
|
298
|
+
},
|
|
299
|
+
"note": (
|
|
300
|
+
"turn ended after the interaction was resolved (the model "
|
|
301
|
+
"did not resume); the last assistant message was a "
|
|
302
|
+
"tool-calls tail"
|
|
303
|
+
),
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if last is None:
|
|
307
|
+
# startup race: prompt accepted but no assistant message yet
|
|
308
|
+
return {"state": "working", "tree": sorted(tree)}
|
|
309
|
+
|
|
310
|
+
info = last.get("info") or {}
|
|
311
|
+
finish = info.get("finish")
|
|
312
|
+
msg_created = msg_created_ms(last)
|
|
313
|
+
|
|
314
|
+
# An assistant message that errored is a terminal failure — EXCEPT an
|
|
315
|
+
# abort: OpenCode 1.18.21 marks the aborted message with
|
|
316
|
+
# error.name == "MessageAbortedError". That is a clean abort, not a
|
|
317
|
+
# failure. (Handled here too, not just via the _aborted set, because
|
|
318
|
+
# the SSE event from the abort can poke the wait loop before abort()
|
|
319
|
+
# adds the sid to _aborted.)
|
|
320
|
+
if info.get("error"):
|
|
321
|
+
err = info["error"]
|
|
322
|
+
err_name = err.get("name") if isinstance(err, dict) else getattr(err, "name", None)
|
|
323
|
+
if err_name == "MessageAbortedError" or root in self._aborted:
|
|
324
|
+
self._aborted.discard(root)
|
|
325
|
+
return {
|
|
326
|
+
"state": "aborted",
|
|
327
|
+
"session_id": root,
|
|
328
|
+
"agent": turn.get("agent"),
|
|
329
|
+
"tree": sorted(tree),
|
|
330
|
+
"note": "turn aborted",
|
|
331
|
+
}
|
|
332
|
+
return {
|
|
333
|
+
"state": "error",
|
|
334
|
+
"session_id": root,
|
|
335
|
+
"error": str(info["error"])[:2000],
|
|
336
|
+
"last_assistant_text": assistant_text(last)[:4000],
|
|
337
|
+
"tree": sorted(tree),
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
# Baseline guard: when we know how many assistant messages existed
|
|
341
|
+
# before the turn (fresh submission), the finishing message must be a
|
|
342
|
+
# NEW one. On resume without durable state the baseline is None and
|
|
343
|
+
# the user-message ordering check below is the authority instead.
|
|
344
|
+
baseline = turn.get("assistant_count_before")
|
|
345
|
+
if baseline is not None:
|
|
346
|
+
new_count = sum(
|
|
347
|
+
1 for m in messages if (m.get("info") or {}).get("role") == "assistant"
|
|
348
|
+
)
|
|
349
|
+
if new_count <= baseline:
|
|
350
|
+
return {"state": "working", "tree": sorted(tree)}
|
|
351
|
+
|
|
352
|
+
# Terminal only when: the last assistant message finished with a
|
|
353
|
+
# terminal reason (not 'tool-calls' = not mid-tool-loop) AND it
|
|
354
|
+
# belongs to the CURRENT turn. Belonging = created after the user
|
|
355
|
+
# prompt that started the turn (robust across controller restarts:
|
|
356
|
+
# the in-flight turn's assistant message was created after its user
|
|
357
|
+
# prompt, while any older terminal message predates it).
|
|
358
|
+
if finish and finish != "tool-calls":
|
|
359
|
+
user_msg = last_user_message(messages)
|
|
360
|
+
user_created = msg_created_ms(user_msg)
|
|
361
|
+
belongs_to_turn = (
|
|
362
|
+
(user_msg is not None and msg_created >= user_created - 5000)
|
|
363
|
+
or msg_created >= turn["submitted_at_ms"] - 5000
|
|
364
|
+
)
|
|
365
|
+
if belongs_to_turn:
|
|
366
|
+
diffs = await self.oc.diff(
|
|
367
|
+
root, directory, message_id=(user_msg or {}).get("info", {}).get("id")
|
|
368
|
+
)
|
|
369
|
+
now_ms = int(time.time() * 1000)
|
|
370
|
+
dsummary = diff_summary(diffs)
|
|
371
|
+
text = assistant_text(last)
|
|
372
|
+
if finish == "length" and classify_length_finish(dsummary, text) == "error":
|
|
373
|
+
# Output budget exhausted with NO deliverable (empty diff,
|
|
374
|
+
# anecdotal text): the agent re-deliberated until the cap
|
|
375
|
+
# and produced nothing. Return a structured error so the
|
|
376
|
+
# supervisor can send a short corrective turn on the SAME
|
|
377
|
+
# session instead of receiving a silent "completed".
|
|
378
|
+
return {
|
|
379
|
+
"state": "error",
|
|
380
|
+
"code": "output_limit_reached",
|
|
381
|
+
"session_id": root,
|
|
382
|
+
"agent": turn.get("agent"),
|
|
383
|
+
"directory": directory,
|
|
384
|
+
"last_assistant_text": text[:4000],
|
|
385
|
+
"tree": sorted(tree),
|
|
386
|
+
"error": (
|
|
387
|
+
"agent exhausted its output budget (finish='length') "
|
|
388
|
+
"before producing a deliverable"
|
|
389
|
+
),
|
|
390
|
+
"hint": (
|
|
391
|
+
"send a short corrective turn on the SAME session via opencode_run "
|
|
392
|
+
"(same session_id): instruct the agent to stop re-analyzing and "
|
|
393
|
+
"execute the pending action using the conclusions already "
|
|
394
|
+
"established in the previous turn. Do NOT start a new session "
|
|
395
|
+
"unless this one is unrecoverable."
|
|
396
|
+
),
|
|
397
|
+
}
|
|
398
|
+
result: dict[str, Any] = {
|
|
399
|
+
"state": "completed",
|
|
400
|
+
"session_id": root,
|
|
401
|
+
"agent": turn.get("agent"),
|
|
402
|
+
"last_assistant_text": text,
|
|
403
|
+
"diff": dsummary,
|
|
404
|
+
"tree": sorted(tree),
|
|
405
|
+
"timing": {
|
|
406
|
+
"submitted_at_ms": turn["submitted_at_ms"],
|
|
407
|
+
"completed_at_ms": now_ms,
|
|
408
|
+
"elapsed_ms": now_ms - turn["submitted_at_ms"],
|
|
409
|
+
},
|
|
410
|
+
}
|
|
411
|
+
if finish == "length":
|
|
412
|
+
result["warning"] = (
|
|
413
|
+
"assistant message ended with finish='length' (output token cap "
|
|
414
|
+
"reached) — the answer may be truncated"
|
|
415
|
+
)
|
|
416
|
+
return result
|
|
417
|
+
|
|
418
|
+
# busy-then-idle with a tool-calls tail: the model may still be
|
|
419
|
+
# composing the next message; keep waiting (the safety poke bounds it).
|
|
420
|
+
return {"state": "working", "tree": tree}
|
|
421
|
+
|
|
422
|
+
# ------------------------------------------------------------------ #
|
|
423
|
+
# The blocking wait (shared by run / answer / permission)
|
|
424
|
+
# ------------------------------------------------------------------ #
|
|
425
|
+
|
|
426
|
+
async def _wait(
|
|
427
|
+
self,
|
|
428
|
+
turn: dict[str, Any],
|
|
429
|
+
timeout: float,
|
|
430
|
+
deadline_override: float | None = None,
|
|
431
|
+
) -> dict[str, Any]:
|
|
432
|
+
"""Block until the turn ends. Returns a normalized result dict:
|
|
433
|
+
state in {completed, error, needs_agent_input, timeout}.
|
|
434
|
+
"""
|
|
435
|
+
sid = turn["session_id"]
|
|
436
|
+
self._active = turn
|
|
437
|
+
turn.setdefault("tree", {sid})
|
|
438
|
+
save_turn_state(turn)
|
|
439
|
+
sse_task = asyncio.create_task(self._subscribe(turn))
|
|
440
|
+
if deadline_override is not None:
|
|
441
|
+
deadline = deadline_override
|
|
442
|
+
else:
|
|
443
|
+
deadline = time.monotonic() + timeout
|
|
444
|
+
try:
|
|
445
|
+
while True:
|
|
446
|
+
remaining = deadline - time.monotonic()
|
|
447
|
+
if remaining <= 0:
|
|
448
|
+
return {
|
|
449
|
+
"ok": False,
|
|
450
|
+
"state": "timeout",
|
|
451
|
+
"session_id": sid,
|
|
452
|
+
"error": f"wait exceeded {timeout:.0f}s; session left running",
|
|
453
|
+
"hint": (
|
|
454
|
+
"resume the same turn with opencode_answer/"
|
|
455
|
+
"opencode_permission if it needs input, or continue "
|
|
456
|
+
"with opencode_run (same session_id); use "
|
|
457
|
+
"opencode_inspect (diagnostic) or opencode_abort"
|
|
458
|
+
),
|
|
459
|
+
}
|
|
460
|
+
ev = self._poke(sid)
|
|
461
|
+
ev.clear()
|
|
462
|
+
try:
|
|
463
|
+
poke_timeout = min(RECONCILE_POKE_TIMEOUT, remaining)
|
|
464
|
+
await asyncio.wait_for(ev.wait(), timeout=poke_timeout)
|
|
465
|
+
except asyncio.TimeoutError:
|
|
466
|
+
pass # safety poke → reconcile anyway
|
|
467
|
+
# FAST-PATH abort: if we were asked to abort this session,
|
|
468
|
+
# return immediately WITHOUT any REST reconciliation. This is
|
|
469
|
+
# checked here (not only in _reconcile) because _reconcile's
|
|
470
|
+
# first calls (_refresh_tree / status_map / messages) can be
|
|
471
|
+
# slow or blocked, and we must not let a pending abort wait for
|
|
472
|
+
# them. The abort POST has already been fired (see abort()).
|
|
473
|
+
if sid in self._aborted:
|
|
474
|
+
self._aborted.discard(sid)
|
|
475
|
+
# The turn is terminating (aborted) — clear durable state so
|
|
476
|
+
# a later opencode_run on this session does not wrongly
|
|
477
|
+
# treat it as an in-flight turn and try to resume it.
|
|
478
|
+
clear_turn_state(sid)
|
|
479
|
+
return self._finalize({
|
|
480
|
+
"state": "aborted",
|
|
481
|
+
"session_id": sid,
|
|
482
|
+
"agent": turn.get("agent"),
|
|
483
|
+
"tree": sorted(turn.get("tree") or {sid}),
|
|
484
|
+
"note": "abort requested via opencode_abort",
|
|
485
|
+
}, turn)
|
|
486
|
+
result = await self._reconcile(turn)
|
|
487
|
+
state = result["state"]
|
|
488
|
+
if state in ("completed", "error", "aborted", "needs_agent_input"):
|
|
489
|
+
if state in ("completed", "error", "aborted"):
|
|
490
|
+
clear_turn_state(sid)
|
|
491
|
+
return self._finalize(result, turn)
|
|
492
|
+
# working → loop
|
|
493
|
+
finally:
|
|
494
|
+
self._active = None
|
|
495
|
+
sse_task.cancel()
|
|
496
|
+
try:
|
|
497
|
+
await sse_task
|
|
498
|
+
except (asyncio.CancelledError, Exception): # noqa: BLE001
|
|
499
|
+
pass
|
|
500
|
+
|
|
501
|
+
def _finalize(self, result: dict[str, Any], turn: dict[str, Any]) -> dict[str, Any]:
|
|
502
|
+
"""Normalize terminal / needs-input results for the MCP layer."""
|
|
503
|
+
state = result["state"]
|
|
504
|
+
if state == "completed":
|
|
505
|
+
result["ok"] = True
|
|
506
|
+
result["directory"] = turn["directory"]
|
|
507
|
+
return result
|
|
508
|
+
if state == "error":
|
|
509
|
+
result["ok"] = False
|
|
510
|
+
return result
|
|
511
|
+
if state == "aborted":
|
|
512
|
+
result["ok"] = True
|
|
513
|
+
result["directory"] = turn["directory"]
|
|
514
|
+
return result
|
|
515
|
+
# needs_agent_input: the MCP tool returns this to the Hermes LLM,
|
|
516
|
+
# which answers via opencode_answer / opencode_permission (same turn).
|
|
517
|
+
result["ok"] = True
|
|
518
|
+
result["directory"] = turn["directory"]
|
|
519
|
+
if result.get("kind") == "question":
|
|
520
|
+
q = result["question"]
|
|
521
|
+
result["question_id"] = q.get("id")
|
|
522
|
+
subqs = q.get("questions") or []
|
|
523
|
+
result["questions"] = [
|
|
524
|
+
{
|
|
525
|
+
"header": sq.get("header"),
|
|
526
|
+
"question": sq.get("question"),
|
|
527
|
+
"options": [
|
|
528
|
+
{
|
|
529
|
+
"label": o.get("label"),
|
|
530
|
+
"description": o.get("description"),
|
|
531
|
+
}
|
|
532
|
+
for o in (sq.get("options") or [])
|
|
533
|
+
],
|
|
534
|
+
"multiple": bool(sq.get("multiple")),
|
|
535
|
+
"custom": bool(sq.get("custom")),
|
|
536
|
+
}
|
|
537
|
+
for sq in subqs
|
|
538
|
+
]
|
|
539
|
+
# exact labels the server will accept (validation happens here)
|
|
540
|
+
result["valid_labels"] = [
|
|
541
|
+
[o.get("label") for o in (sq.get("options") or []) if o.get("label")]
|
|
542
|
+
for sq in subqs
|
|
543
|
+
]
|
|
544
|
+
else:
|
|
545
|
+
p = result["permission"]
|
|
546
|
+
result["permission_id"] = p.get("id")
|
|
547
|
+
result["allowed_replies"] = ["once", "always", "reject"]
|
|
548
|
+
result["permission_summary"] = {
|
|
549
|
+
"permission": p.get("permission"),
|
|
550
|
+
"patterns": p.get("patterns"),
|
|
551
|
+
"metadata": p.get("metadata"),
|
|
552
|
+
}
|
|
553
|
+
return result
|
|
554
|
+
|
|
555
|
+
# ------------------------------------------------------------------ #
|
|
556
|
+
# run
|
|
557
|
+
# ------------------------------------------------------------------ #
|
|
558
|
+
|
|
559
|
+
async def run(
|
|
560
|
+
self,
|
|
561
|
+
directory: str,
|
|
562
|
+
task: str,
|
|
563
|
+
agent: str | None = None,
|
|
564
|
+
model: str | None = None,
|
|
565
|
+
session_id: str | None = None,
|
|
566
|
+
timeout: float = DEFAULT_RUN_TIMEOUT,
|
|
567
|
+
) -> dict[str, Any]:
|
|
568
|
+
if not os.path.isdir(directory):
|
|
569
|
+
return {"ok": False, "error": f"directory does not exist: {directory}"}
|
|
570
|
+
|
|
571
|
+
try:
|
|
572
|
+
model_obj = parse_model(model) if model else None
|
|
573
|
+
except ValueError as exc:
|
|
574
|
+
return {"ok": False, "error": str(exc)}
|
|
575
|
+
|
|
576
|
+
# -- agent validation (dynamic, against the live /agent list) ------ #
|
|
577
|
+
if agent:
|
|
578
|
+
try:
|
|
579
|
+
agents = await self.oc.agents(directory)
|
|
580
|
+
except OpenCodeError as exc:
|
|
581
|
+
return {"ok": False, "error": f"cannot list agents: {exc}"}
|
|
582
|
+
_, err = validate_agent(agents, agent)
|
|
583
|
+
if err:
|
|
584
|
+
return err
|
|
585
|
+
|
|
586
|
+
# create or reuse session
|
|
587
|
+
if session_id:
|
|
588
|
+
sid = session_id
|
|
589
|
+
try:
|
|
590
|
+
await self.oc.session(sid)
|
|
591
|
+
except OpenCodeError as exc:
|
|
592
|
+
return {
|
|
593
|
+
"ok": False,
|
|
594
|
+
"error": f"session {sid} not found: {exc}",
|
|
595
|
+
}
|
|
596
|
+
# RESUME semantics. Authoritative signal that a turn is in flight
|
|
597
|
+
# is the durable turn state (written at submission, cleared on
|
|
598
|
+
# terminal). status_map is NOT reliable here: while another
|
|
599
|
+
# controller process holds the SSE stream it returns {} (1.18.21
|
|
600
|
+
# quirk), so we must not use "not busy" alone to decide to
|
|
601
|
+
# resubmit — that would wrongly send a NEW prompt onto a turn that
|
|
602
|
+
# is still running (forbidden).
|
|
603
|
+
state = load_turn_state(sid)
|
|
604
|
+
in_flight = state is not None
|
|
605
|
+
if not in_flight:
|
|
606
|
+
# No durable state: check status_map (works when no other
|
|
607
|
+
# controller is holding the stream) as a secondary signal.
|
|
608
|
+
statuses = await self.oc.status_map(directory)
|
|
609
|
+
in_flight = (statuses.get(sid) or {}).get("type") in ("busy", "retry")
|
|
610
|
+
if in_flight:
|
|
611
|
+
# A turn is in flight (or was, and may have just finished
|
|
612
|
+
# while we were disconnected). Re-enter the wait loop on the
|
|
613
|
+
# SAME turn — NEVER resubmit the prompt. `task` is ignored.
|
|
614
|
+
# If the turn already finished, _wait's reconcile returns the
|
|
615
|
+
# terminal state promptly (or, if it finished with no terminal
|
|
616
|
+
# message, the post-interaction stall logic / baseline guard
|
|
617
|
+
# resolves it).
|
|
618
|
+
turn = state or new_turn(sid, directory, agent=agent)
|
|
619
|
+
if turn.get("assistant_count_before") is None:
|
|
620
|
+
pre = await self.oc.messages(sid, directory)
|
|
621
|
+
turn["assistant_count_before"] = sum(
|
|
622
|
+
1 for m in pre if (m.get("info") or {}).get("role") == "assistant"
|
|
623
|
+
)
|
|
624
|
+
return await self._wait(turn, timeout)
|
|
625
|
+
else:
|
|
626
|
+
try:
|
|
627
|
+
sess = await self.oc.create_session(
|
|
628
|
+
directory, title=task[:80], agent=agent, model=model_obj
|
|
629
|
+
)
|
|
630
|
+
except OpenCodeError as exc:
|
|
631
|
+
return {"ok": False, "error": f"failed to create session: {exc}"}
|
|
632
|
+
sid = (sess or {}).get("id")
|
|
633
|
+
if not sid:
|
|
634
|
+
return {"ok": False, "error": "failed to create session", "raw": sess}
|
|
635
|
+
|
|
636
|
+
# NEW prompt path (new session, or a fresh turn on an existing session).
|
|
637
|
+
# `agent` is REQUIRED here — we must not silently fall back to
|
|
638
|
+
# default_agent. (On the resume path above we already returned, so an
|
|
639
|
+
# in-flight turn never reaches this point.)
|
|
640
|
+
if not agent:
|
|
641
|
+
return {
|
|
642
|
+
"ok": False,
|
|
643
|
+
"error": (
|
|
644
|
+
"agent is required for a new turn. Pass the primary agent "
|
|
645
|
+
"explicitly (e.g. 'build', 'plan', or a project agent). "
|
|
646
|
+
"This is not a resume of an in-flight turn."
|
|
647
|
+
),
|
|
648
|
+
"session_id": sid,
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
turn = new_turn(sid, directory, agent=agent)
|
|
652
|
+
# Baseline for the completion heuristic: the finishing assistant
|
|
653
|
+
# message must be NEWER than the messages that already existed.
|
|
654
|
+
pre_messages = await self.oc.messages(sid, directory)
|
|
655
|
+
turn["assistant_count_before"] = sum(
|
|
656
|
+
1 for m in pre_messages if (m.get("info") or {}).get("role") == "assistant"
|
|
657
|
+
)
|
|
658
|
+
text = task.rstrip() + ("\n\n" + SUPERVISOR_GUARD if task.strip() else "")
|
|
659
|
+
try:
|
|
660
|
+
await self.oc.prompt_async(sid, text, agent=agent, model=model_obj, directory=directory)
|
|
661
|
+
except OpenCodeError as exc:
|
|
662
|
+
clear_turn_state(sid)
|
|
663
|
+
return {
|
|
664
|
+
"ok": False,
|
|
665
|
+
"error": f"failed to submit prompt: {exc}",
|
|
666
|
+
"session_id": sid,
|
|
667
|
+
}
|
|
668
|
+
return await self._wait(turn, timeout)
|
|
669
|
+
|
|
670
|
+
# ------------------------------------------------------------------ #
|
|
671
|
+
# Resume primitives (answer / permission) — same turn, no resubmit
|
|
672
|
+
# ------------------------------------------------------------------ #
|
|
673
|
+
|
|
674
|
+
async def _mark_interaction_answered(
|
|
675
|
+
self, session_id: str, request_id: str, directory: str
|
|
676
|
+
) -> None:
|
|
677
|
+
"""Record that we just answered a question / decided a permission.
|
|
678
|
+
|
|
679
|
+
Captures the assistant message count at answer time (the model's
|
|
680
|
+
in-flight message is already present). This count is the robust
|
|
681
|
+
discriminator for post-interaction stall detection: if the model
|
|
682
|
+
RESUMES it creates a new assistant message (count grows) or updates
|
|
683
|
+
the finish to a terminal reason (normal heuristic catches it); if the
|
|
684
|
+
turn is TERMINATED by the interaction (e.g. a permission reject) the
|
|
685
|
+
count stays put and the last message stays at a non-terminal
|
|
686
|
+
finish. Adds the request id to `ignore_ids` and resets the idle
|
|
687
|
+
clock. Persisted so it survives a controller restart.
|
|
688
|
+
"""
|
|
689
|
+
state = load_turn_state(session_id)
|
|
690
|
+
if state is None:
|
|
691
|
+
return
|
|
692
|
+
state["ignore_ids"].add(request_id)
|
|
693
|
+
state["answered_interaction"] = True
|
|
694
|
+
state["idle_since_ms"] = None
|
|
695
|
+
try:
|
|
696
|
+
msgs = await self.oc.messages(session_id, directory)
|
|
697
|
+
state["assistant_count_at_answer"] = sum(
|
|
698
|
+
1 for m in msgs if (m.get("info") or {}).get("role") == "assistant"
|
|
699
|
+
)
|
|
700
|
+
except OpenCodeError:
|
|
701
|
+
state["assistant_count_at_answer"] = None
|
|
702
|
+
save_turn_state(state)
|
|
703
|
+
|
|
704
|
+
async def _resume_wait(
|
|
705
|
+
self,
|
|
706
|
+
session_id: str,
|
|
707
|
+
directory: str,
|
|
708
|
+
timeout: float,
|
|
709
|
+
deadline_override: float | None = None,
|
|
710
|
+
) -> dict[str, Any]:
|
|
711
|
+
"""Re-enter the wait loop on an already-submitted turn.
|
|
712
|
+
|
|
713
|
+
The turn state (submission time, ignored request ids) is loaded from
|
|
714
|
+
the durable state file if present (controller-restart case), else
|
|
715
|
+
reconstructed. The prompt is NEVER resubmitted.
|
|
716
|
+
"""
|
|
717
|
+
state = load_turn_state(session_id)
|
|
718
|
+
if state is not None:
|
|
719
|
+
turn = state
|
|
720
|
+
turn["tree"] = {session_id}
|
|
721
|
+
else:
|
|
722
|
+
# No durable state (e.g. pre-migration or cleared): reconstruct.
|
|
723
|
+
# submitted_at_ms = now is safe for completion detection only if
|
|
724
|
+
# the finishing message postdates it; if the turn already finished
|
|
725
|
+
# before we resumed, reconcile will report it via the last
|
|
726
|
+
# assistant message (created >= now-5s is the only risk window,
|
|
727
|
+
# and that window means the turn is brand new anyway).
|
|
728
|
+
turn = new_turn(session_id, directory)
|
|
729
|
+
return await self._wait(turn, timeout, deadline_override=deadline_override)
|
|
730
|
+
|
|
731
|
+
async def answer(
|
|
732
|
+
self,
|
|
733
|
+
directory: str,
|
|
734
|
+
session_id: str,
|
|
735
|
+
question_id: str,
|
|
736
|
+
answers: list[Any],
|
|
737
|
+
timeout: float = DEFAULT_RUN_TIMEOUT,
|
|
738
|
+
) -> dict[str, Any]:
|
|
739
|
+
# 1) validate the question is still pending (REST is the source of truth)
|
|
740
|
+
try:
|
|
741
|
+
quests = await self.oc.questions(directory)
|
|
742
|
+
except OpenCodeError as exc:
|
|
743
|
+
return {"ok": False, "error": f"cannot list pending questions: {exc}"}
|
|
744
|
+
quest = next((q for q in quests if q.get("id") == question_id), None)
|
|
745
|
+
if quest is None:
|
|
746
|
+
return {
|
|
747
|
+
"ok": False,
|
|
748
|
+
"error": (
|
|
749
|
+
f"question {question_id} is not pending anymore (already answered "
|
|
750
|
+
"or consumed). The turn may have moved on — use opencode_inspect "
|
|
751
|
+
"(diagnostic) or continue with opencode_run."
|
|
752
|
+
),
|
|
753
|
+
"session_id": session_id,
|
|
754
|
+
}
|
|
755
|
+
if quest.get("sessionID") != session_id:
|
|
756
|
+
return {
|
|
757
|
+
"ok": False,
|
|
758
|
+
"error": (
|
|
759
|
+
f"question {question_id} belongs to session "
|
|
760
|
+
f"{quest.get('sessionID')}, not {session_id}"
|
|
761
|
+
),
|
|
762
|
+
}
|
|
763
|
+
# 2) validate answers against the question's options
|
|
764
|
+
ok, err = validate_question_answers(quest, answers)
|
|
765
|
+
if not ok:
|
|
766
|
+
return {"ok": False, "error": err, "session_id": session_id, "question_id": question_id}
|
|
767
|
+
# 3) post the answer
|
|
768
|
+
normalized = [list(a) if isinstance(a, list) else [a] for a in answers]
|
|
769
|
+
try:
|
|
770
|
+
await self.oc.reply_question(question_id, normalized, directory)
|
|
771
|
+
except OpenCodeError as exc:
|
|
772
|
+
return {
|
|
773
|
+
"ok": False,
|
|
774
|
+
"error": f"failed to post answer: {exc}",
|
|
775
|
+
"session_id": session_id,
|
|
776
|
+
"question_id": question_id,
|
|
777
|
+
}
|
|
778
|
+
# 4) mark answered + re-enter the wait loop on the SAME turn
|
|
779
|
+
await self._mark_interaction_answered(session_id, question_id, directory)
|
|
780
|
+
return await self._resume_wait(session_id, directory, timeout)
|
|
781
|
+
|
|
782
|
+
async def permission(
|
|
783
|
+
self,
|
|
784
|
+
directory: str,
|
|
785
|
+
session_id: str,
|
|
786
|
+
permission_id: str,
|
|
787
|
+
reply: str,
|
|
788
|
+
timeout: float = DEFAULT_RUN_TIMEOUT,
|
|
789
|
+
) -> dict[str, Any]:
|
|
790
|
+
ok, err = validate_permission_reply(reply)
|
|
791
|
+
if not ok:
|
|
792
|
+
return {
|
|
793
|
+
"ok": False,
|
|
794
|
+
"error": err,
|
|
795
|
+
"session_id": session_id,
|
|
796
|
+
"permission_id": permission_id,
|
|
797
|
+
}
|
|
798
|
+
# 1) validate the permission is still pending
|
|
799
|
+
try:
|
|
800
|
+
perms = await self.oc.permissions(directory)
|
|
801
|
+
except OpenCodeError as exc:
|
|
802
|
+
return {"ok": False, "error": f"cannot list pending permissions: {exc}"}
|
|
803
|
+
perm = next((p for p in perms if p.get("id") == permission_id), None)
|
|
804
|
+
if perm is None:
|
|
805
|
+
return {
|
|
806
|
+
"ok": False,
|
|
807
|
+
"error": (
|
|
808
|
+
f"permission {permission_id} is not pending anymore (already answered "
|
|
809
|
+
"or consumed). The turn may have moved on — use opencode_inspect "
|
|
810
|
+
"(diagnostic) or continue with opencode_run."
|
|
811
|
+
),
|
|
812
|
+
"session_id": session_id,
|
|
813
|
+
}
|
|
814
|
+
if perm.get("sessionID") != session_id:
|
|
815
|
+
return {
|
|
816
|
+
"ok": False,
|
|
817
|
+
"error": (
|
|
818
|
+
f"permission {permission_id} belongs to session "
|
|
819
|
+
f"{perm.get('sessionID')}, not {session_id}"
|
|
820
|
+
),
|
|
821
|
+
}
|
|
822
|
+
# 2) post the decision
|
|
823
|
+
try:
|
|
824
|
+
await self.oc.reply_permission(permission_id, reply, directory)
|
|
825
|
+
except OpenCodeError as exc:
|
|
826
|
+
return {
|
|
827
|
+
"ok": False,
|
|
828
|
+
"error": f"failed to post permission reply: {exc}",
|
|
829
|
+
"session_id": session_id,
|
|
830
|
+
"permission_id": permission_id,
|
|
831
|
+
}
|
|
832
|
+
# 3) mark answered + re-enter the wait loop on the SAME turn
|
|
833
|
+
await self._mark_interaction_answered(session_id, permission_id, directory)
|
|
834
|
+
return await self._resume_wait(session_id, directory, timeout)
|
|
835
|
+
|
|
836
|
+
# ------------------------------------------------------------------ #
|
|
837
|
+
# abort / inspect / sessions
|
|
838
|
+
# ------------------------------------------------------------------ #
|
|
839
|
+
|
|
840
|
+
async def abort(self, session_id: str | None) -> dict[str, Any]:
|
|
841
|
+
sid = session_id or (self._active or {}).get("session_id")
|
|
842
|
+
if not sid:
|
|
843
|
+
return {"ok": False, "error": "no session_id given and no active run"}
|
|
844
|
+
# Signal the active wait IMMEDIATELY (before the slow POST). The
|
|
845
|
+
# abort POST makes the server drain the in-flight generation and can
|
|
846
|
+
# take tens of seconds; if we awaited it first, the run's wait loop
|
|
847
|
+
# would time out before we ever set the flag. Registering + poking
|
|
848
|
+
# first lets the running opencode_run return state=aborted at once,
|
|
849
|
+
# while the POST proceeds in the background to actually stop the turn.
|
|
850
|
+
self._aborted.add(sid)
|
|
851
|
+
self._poke_set(sid)
|
|
852
|
+
ok = await self.oc.abort(sid)
|
|
853
|
+
return {"ok": ok, "session_id": sid, "aborted": ok}
|
|
854
|
+
|
|
855
|
+
async def inspect(self, session_id: str | None) -> dict[str, Any]:
|
|
856
|
+
sid = session_id or (self._active or {}).get("session_id")
|
|
857
|
+
if not sid:
|
|
858
|
+
return {"ok": False, "error": "no session_id given and no active run"}
|
|
859
|
+
try:
|
|
860
|
+
sess = await self.oc.session(sid)
|
|
861
|
+
except OpenCodeError as exc:
|
|
862
|
+
return {"ok": False, "error": f"session not found: {exc}"}
|
|
863
|
+
directory = sess.get("directory") or (self._active or {}).get("directory")
|
|
864
|
+
tree = {sid}
|
|
865
|
+
await self._refresh_tree(sid, tree)
|
|
866
|
+
statuses = await self.oc.status_map(directory)
|
|
867
|
+
perms = [p for p in await self.oc.permissions(directory) if p.get("sessionID") in tree]
|
|
868
|
+
quests = [q for q in await self.oc.questions(directory) if q.get("sessionID") in tree]
|
|
869
|
+
messages = await self.oc.messages(sid, directory)
|
|
870
|
+
last = last_assistant(messages)
|
|
871
|
+
return {
|
|
872
|
+
"ok": True,
|
|
873
|
+
"session_id": sid,
|
|
874
|
+
"sse_connected": self._sse_connected,
|
|
875
|
+
"sse_reconnects": self._sse_reconnects,
|
|
876
|
+
"root_status": statuses.get(sid, {"type": "idle"}),
|
|
877
|
+
"tree": sorted(tree),
|
|
878
|
+
"pending_permissions": perms,
|
|
879
|
+
"pending_questions": quests,
|
|
880
|
+
"message_count": len(messages),
|
|
881
|
+
"last_assistant_finish": (last.get("info") or {}).get("finish") if last else None,
|
|
882
|
+
"last_assistant_text": assistant_text(last)[:4000],
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
async def sessions(self, directory: str) -> dict[str, Any]:
|
|
886
|
+
items = await self.oc.list_sessions(directory)
|
|
887
|
+
out = [
|
|
888
|
+
{
|
|
889
|
+
"id": s.get("id"),
|
|
890
|
+
"title": s.get("title"),
|
|
891
|
+
"directory": s.get("directory"),
|
|
892
|
+
"time": s.get("time") or {},
|
|
893
|
+
}
|
|
894
|
+
for s in items
|
|
895
|
+
]
|
|
896
|
+
return {"ok": True, "directory": directory, "count": len(out), "sessions": out[:200]}
|