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.
@@ -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]}