Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 12 additions & 8 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -503,7 +503,7 @@ automatically armed by installing Möbius.

## Chat scroll + steer contract

**Owner-authoritative contract — v1.9 (2026-07-24).** This section is the
**Owner-authoritative contract — v1.10 (2026-07-26).** This section is the
canonical source of truth for how a chat scrolls and steers. When implementation,
comments, and this contract disagree, the implementation/comments are the bug:
fix behavior to match this contract. If a real case is unspecified or the desired
Expand Down Expand Up @@ -539,8 +539,9 @@ and attaches their rule ids to new diagnostic chats. The Playwright lock-in spec
seeing an older user row never qualifies. An otherwise unreachable anchor clamps
to real conversation content before visibility is decided. R6's transient
question-submit hold is the sole exception: it may reserve only the exact tail
deficit required to keep the answered card at its frozen offset through mobile
viewport growth, and that intent is never persisted.
deficit required for a stable card handoff while the viewport size is unchanged.
It is never persisted and must release to the unanswered card's prior mode before
a keyboard or other viewport resize is laid out.
- **R2 — One send rule everywhere.** The first visible user message always pins to
the viewport top. Every subsequent direct, queued, promoted, or steered message
pins when its submit-time DOM snapshot is at the real-content tail. Geometry is
Expand Down Expand Up @@ -629,10 +630,12 @@ and attaches their rule ids to new diagnostic chats. The Playwright lock-in spec
card enters its pending state or output resumes, the controller snapshots the
currently visible message and its exact viewport offset as `ANCHOR_AT`. Resumed
output grows without dragging the reader, even when the chat had been following
the tail before Submit. If a mobile viewport grows before that output arrives,
the dynamic spacer temporarily reserves exactly enough room to keep the anchor
target reachable; the reservation disappears as real content replaces it and
the transient reservation intent is stripped before persistence. A failed answer
the tail before Submit. That exact hold is scoped to the viewport where Submit
occurred. If the mobile keyboard changes the viewport, the controller restores
the mode that owned the unanswered card before sizing the new geometry. Answering
therefore adds no movement of its own, while the keyboard still moves the card
exactly as it would have moved unanswered. The transient hold is stripped before
persistence. A failed answer
keeps that settled reading anchor for the retryable card rather than manufacturing
follow intent again.
The source handoff
Expand Down Expand Up @@ -667,7 +670,8 @@ path means routing it through the same entries rather than inventing another rul
| Viewport/keyboard changes | `PIN_USER_MSG` | same `PIN_USER_MSG` | Reapply pin after resize; never infer intent from keyboard-open geometry |
| Viewport/keyboard changes | follow or anchor hold | same follow if still at tail, otherwise hold anchor | Never creates follow |
| Chat exits/backgrounds/returns | any | `ANCHOR_AT` | Restore exact saved anchor |
| In-process question is answered | any | `ANCHOR_AT` on current visible row; same active assistant row | Hold exact visible anchor through card reflow and resumed output |
| In-process question is answered | any | transient `ANCHOR_AT` over the prior mode; same active assistant row | Hold exact visible anchor through same-viewport card reflow and resumed output |
| Viewport/keyboard changes after question submission | transient question anchor | pre-submit unanswered-card mode | Apply ordinary viewport behavior; answering adds no extra movement |
| Live assistant row settles to the durable transcript | any | same mode and row identity | None (except R3's exact spacer handoff) |
| Offscreen question or paused-turn nudge tapped | any hold | `ANCHOR_AT` at physical tail | User-requested one-shot move; clears the overlaid composer |

Expand Down
87 changes: 86 additions & 1 deletion backend/app/claude_sdk_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,19 @@
import json
import logging
import os
import signal
import shutil
import time
from collections import deque
from typing import Any
from uuid import uuid4

from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient, HookMatcher
from claude_agent_sdk import (
ClaudeAgentOptions,
ClaudeSDKClient,
HookMatcher,
ProcessError,
)
from claude_agent_sdk.types import (
AssistantMessage,
PermissionResultAllow,
Expand Down Expand Up @@ -149,6 +155,26 @@ def _claude_process_group_id(client: ClaudeSDKClient) -> int | None:
return pgid


def _claude_process_was_force_stopped(client: ClaudeSDKClient) -> bool:
"""Whether the SDK transport recorded one of Möbius's stop signals.

The SDK's background reader converts ``ProcessError`` into a plain
``Exception`` before it reaches ``receive_response()``. Keep this predicate
narrow by reading the still-typed transport outcome and accepting only the
TERM/KILL return codes ``terminate_process_group`` can cause. A different
typed process failure that merely races a Stop must remain visible to the
owner. The transport is already an intentional private SDK seam here
(``_claude_process_group_id`` reads its child pid); if the SDK changes shape,
this fails closed and the error remains visible.
"""
transport = getattr(client, "_transport", None)
exit_error = getattr(transport, "_exit_error", None)
return (
isinstance(exit_error, ProcessError)
and exit_error.exit_code in (-signal.SIGTERM, -signal.SIGKILL)
)


def _terminate_claude_process_group(pgid: int | None) -> bool:
return terminate_process_group(
pgid,
Expand Down Expand Up @@ -305,6 +331,11 @@ def __init__(self, client: ClaudeSDKClient, chat_id: str):
# Never signal a retained PGID twice; the kernel can eventually reuse it
# after the first hard stop.
self._force_stop_started = False
# Set synchronously before interrupt()'s first await. Claude reports both a
# Möbius-requested Stop and an unexpected provider interruption as an
# error-shaped ResultMessage, so the runner needs this local ownership fact
# to keep a deliberate Stop from overwriting its resumable pause note.
self._interrupt_requested = False
# FIFO of mid-turn steer texts: two rapid sends must both reach Claude
# (both are already persisted to the transcript), so a single slot would
# silently drop the first. The runner drains the whole list on interrupt.
Expand Down Expand Up @@ -424,6 +455,7 @@ async def interrupt(self) -> None:
dropping them here: they were never in the transcript, and Stop's own
clear-and-resend path is what preserves them.
"""
self._interrupt_requested = True
self.pending_steer = []
self._steer_requested = False
self._steer_user_msgs = []
Expand All @@ -438,6 +470,10 @@ async def interrupt(self) -> None:
"runner is wedged",
)

@property
def interrupt_requested(self) -> bool:
return self._interrupt_requested

async def stop(self, timeout: float = 2.0) -> bool:
"""Interrupts the SDK run and waits up to `timeout` seconds."""
try:
Expand Down Expand Up @@ -1570,6 +1606,17 @@ def _capture_stderr(line: str) -> None:
active_client._interrupt_in_flight = True
await client.interrupt()
continue
if (
active_client.interrupt_requested
and isinstance(sdk_msg, ResultMessage)
and sdk_msg.stop_reason == "interrupt"
):
# The SDK describes a deliberate Stop with the same
# error_during_execution envelope it uses for an unexpected
# interruption. Preserve its usage/cost, but do not let the
# provider-shaped error overwrite chat.py's resumable stop note.
terminal["error"] = None
terminal["terminal_status"] = "interrupted"
# Terminal result: the interrupt cycle (if any) is closed, so a
# fresh boundary cut may fire on a later turn.
active_client._interrupt_in_flight = False
Expand Down Expand Up @@ -1599,6 +1646,7 @@ def _capture_stderr(line: str) -> None:
# retry is also empty the finalize backstop records a retry marker.
if (
session_id is not None # a resume (non-first turn)
and not active_client.interrupt_requested # Stop is terminal
and not terminal.get("error") # clean terminal (is_error False)
and terminal.get("api_error_status") != 429 # not a bare 429/park
and not active_client.pending_steer
Expand Down Expand Up @@ -1646,6 +1694,21 @@ def _capture_stderr(line: str) -> None:
# error and finalize() persists a durable error block, instead of the
# old silent `error=None` that logged a clean $0 "done" and let the
# just-consumed user message go unanswered with nothing to reconcile.
if active_client.interrupt_requested:
# A graceful interrupt may close the response stream without its usual
# ResultMessage. The local ownership flag is enough here: there is no
# provider error to suppress, only the resultless end caused by Stop.
log.warning(
"Claude response stream ended after our own stop chat_id=%s",
chat_id,
)
return {
"session_id": current_session_id,
"cost_usd": cost_usd,
"usage": None,
"error": None,
"terminal_status": "interrupted",
}
return {
"session_id": current_session_id,
"cost_usd": cost_usd,
Expand All @@ -1657,6 +1720,28 @@ def _capture_stderr(line: str) -> None:
}
except Exception as exc:
msg = str(exc)
if (
active_client.interrupt_requested
and _claude_process_was_force_stopped(client)
):
# force_stop() SIGTERMs the verified private CLI process group when a
# graceful interrupt times out. The SDK's reader converts the typed
# ProcessError into a plain Exception before it reaches us, so consult
# the still-typed transport outcome rather than matching message text.
# WARNING is deliberate: the owner sees a clean interrupted turn, but
# operators retain the only evidence a coincident CLI crash leaves.
log.warning(
"Claude process exited during our own stop chat_id=%s: %s",
chat_id,
exc,
)
return {
"session_id": current_session_id,
"cost_usd": None,
"usage": None,
"error": None,
"terminal_status": "interrupted",
}
# The SDK raises this generic placeholder when the CLI dies before a
# structured result (early resume failure, auth, crash, OOM/SIGTERM
# kill). Splice in the captured stderr tail ONLY then — gating on the
Expand Down
Loading