Compare commits

..
10 Commits
46 changed files with 2320 additions and 459 deletions

No files matched your search

+21 -2
View File
@@ -45,11 +45,30 @@ RUN apt-get update \
&& rm -rf /var/lib/apt/lists/*
# Bun native binary (glibc): `bunx` runs the stdio MCP servers declared in
# the user's config. The claude CLI itself ships inside the
# claude-agent-sdk wheel (`_bundled/claude`), nothing to install here.
# the user's config.
COPY --from=oven/bun:1-slim /usr/local/bin/bun /usr/local/bin/bun
RUN ln -s /usr/local/bin/bun /usr/local/bin/bunx
# The claude-agent-sdk wheel bundles a CLI, but it trails the npm release:
# 0.2.157 carries 2.1.277, which does not know claude-opus-5-5 and writes an
# [claude-code:unrecognized_model] warning per turn. Install the npm build and
# point BEAVER_CLAUDE_CLI at it; the adapter prefers it over the bundled one.
#
# Only the glibc platform package is pulled: the meta package also drags in the
# musl build, and this image is glibc for the reason stated at the top. The
# tree lands under /opt world-readable because the model runs as beaver-runner,
# not as root.
ARG CLAUDE_CODE_VERSION=2.1.280
RUN mkdir -p /opt/claude-code \
&& cd /opt/claude-code \
&& echo '{"name":"claude-cli-holder","private":true}' > package.json \
&& bun add @anthropic-ai/claude-code-linux-x64@${CLAUDE_CODE_VERSION} \
&& ln -sf /opt/claude-code/node_modules/@anthropic-ai/claude-code-linux-x64/claude \
/usr/local/bin/claude-cli \
&& chmod -R a+rX /opt/claude-code \
&& /usr/local/bin/claude-cli --version
ENV BEAVER_CLAUDE_CLI=/usr/local/bin/claude-cli
# The model process runs as `beaver-runner`, not as the gateway: the
# adapter spawns claude under this uid with a whitelisted environment.
# `acl` lets the entrypoint grant it write access to the vault sub-mounts
+1 -1
View File
@@ -14,7 +14,7 @@ dependencies = [
"anthropic>=0.103.0",
"anyio>=4.13.0",
"argon2-cffi>=25.1.0",
"claude-agent-sdk>=0.2.150",
"claude-agent-sdk>=0.2.157",
"croniter>=6.2.4",
"fastapi>=0.136.1",
"fastmcp>=3.3.1",
+5
View File
@@ -36,3 +36,8 @@ class BaseAgent(BaseModel):
system_prompt: str
expose_mcps: tuple[ExposedMcp, ...] = ()
accept_client_tools: bool = False
@property
def thinking_is_progress(self) -> bool:
"""Whether non-empty thinking is text for the human rather than reasoning."""
return False
+13 -1
View File
@@ -17,13 +17,19 @@ from beaver_gateway.agents.policy import PolicyRule # noqa: TC001
from beaver_gateway.agents.prompts import PromptSource # noqa: TC001
from beaver_gateway.conversations.kinds import KINDS, Kind
__all__ = ["ClaudeAgent", "ClaudeOptions", "Prompts", "SkillSets"]
__all__ = ["PROGRESS_MODELS", "ClaudeAgent", "ClaudeOptions", "Prompts", "SkillSets"]
PROGRESS_MODELS = frozenset({"claude-opus-5-5", "claude-fable-5-1"})
"""Models whose non-empty thinking is the text written between tool calls."""
class ClaudeOptions(BaseModel):
model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
effort: str | None = None
thinking: Mapping[str, str] | None = None
"""Thinking config for the SDK; ``None`` keeps the CLI default."""
include_partial_messages: bool = True
"""Token-level deltas on the wire; off means one delta per finished block."""
@@ -118,6 +124,12 @@ class ClaudeAgent(BaseAgent):
raise ValueError(msg)
return self
@property
def thinking_is_progress(self) -> bool:
"""A progress model, unless ``display: summarized`` brings reasoning."""
summarized = (self.options.thinking or {}).get("display") == "summarized"
return self.model in PROGRESS_MODELS and not summarized
def prompt_for(self, kind: Kind) -> tuple[PromptSource, ...] | None:
return self.prompts.for_kind(kind)
+35 -12
View File
@@ -20,6 +20,7 @@ import tempfile
import time
import uuid
import warnings
from collections import deque
from collections.abc import Mapping
from dataclasses import dataclass, field
from pathlib import Path
@@ -49,7 +50,12 @@ from claude_agent_sdk import (
from beaver_gateway.agents import policy as policy_mod
from beaver_gateway.agents import prompts as prompt_assembly
from beaver_gateway.backends.capture import TurnCapture, TurnUsage
from beaver_gateway.backends.sessions import Session, SessionClient, SessionPool
from beaver_gateway.backends.sessions import (
STDERR_LINES,
Session,
SessionClient,
SessionPool,
)
from beaver_gateway.backends.transcript import (
build_entries,
close_open_tool_uses,
@@ -71,6 +77,7 @@ from beaver_gateway.events.stream import (
build_thinking_delta,
build_tool_use_block_start,
)
from beaver_gateway.security.redact import redact
if TYPE_CHECKING:
from collections.abc import (
@@ -262,12 +269,7 @@ class ClaudeSdkBackend:
await self._pool.close(key)
async def interrupt(self, key: str) -> bool:
live = self._pool.get(key)
if live is None or not live.busy:
return False
live.interrupt_requested = True
await live.client.interrupt()
return True
return await self._pool.interrupt(key)
def live(self, key: str) -> Session | None:
return self._pool.get(key)
@@ -674,7 +676,8 @@ class ClaudeSdkBackend:
async def _spawn(
self, resume: str | None, *, key: str, spec: _SessionSpec
) -> Session:
options = self._build_options(resume, key=key, spec=spec)
stderr: deque[str] = deque(maxlen=STDERR_LINES)
options = self._build_options(resume, key=key, spec=spec, stderr=stderr)
client = self._factory(options)
await client.connect()
_log.info(
@@ -693,6 +696,7 @@ class ClaudeSdkBackend:
session_id=resume,
resumed=resume is not None,
pinned=spec.pinned,
stderr=stderr,
)
live.reader = asyncio.create_task(self._read(live))
return live
@@ -701,7 +705,12 @@ class ClaudeSdkBackend:
return _RunnerClient(options, uid=self._uid)
def _build_options(
self, resume: str | None, *, key: str, spec: _SessionSpec
self,
resume: str | None,
*,
key: str,
spec: _SessionSpec,
stderr: deque[str] | None = None,
) -> ClaudeAgentOptions:
agent = self._agent
opt = agent.options
@@ -726,6 +735,7 @@ class ClaudeSdkBackend:
return ClaudeAgentOptions(
model=agent.model or None,
effort=cast("Any", opt.effort),
thinking=cast("Any", dict(opt.thinking) if opt.thinking else None),
system_prompt=system_prompt,
setting_sources=[],
strict_mcp_config=True,
@@ -746,11 +756,14 @@ class ClaudeSdkBackend:
plugins=cast("Any", plugins),
skills="all" if plugins else None,
max_turns=opt.max_turns,
stderr=lambda line: _log.warning(
"claude[%s]: %s", agent.name, line.rstrip()
),
stderr=lambda line: self._stderr(line.rstrip(), stderr),
)
def _stderr(self, line: str, tail: deque[str] | None) -> None:
_log.warning("claude[%s]: %s", self._agent.name, line)
if tail is not None:
tail.append(redact(line))
def _can_use_tool(
self, key: str
) -> Callable[
@@ -933,7 +946,17 @@ async def _disconnect(live: Session) -> None:
_log.exception("disconnect failed for session %s", live.session_id)
CLI_ENV = "BEAVER_CLAUDE_CLI"
"""Path to a ``claude`` CLI that wins over the one bundled in the wheel."""
def _claude_binary() -> str:
override = os.environ.get(CLI_ENV)
if override:
if not Path(override).is_file():
msg = f"{CLI_ENV} points at {override!r}, which is not a file"
raise FileNotFoundError(msg)
return override
bundled = Path(claude_agent_sdk.__file__).parent / "_bundled" / "claude"
if bundled.is_file():
return str(bundled)
+118
View File
@@ -0,0 +1,118 @@
"""What runs under the gateway: the process tree with memory and CPU, and the kill."""
from __future__ import annotations
import contextlib
import os
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import psutil
from beaver_gateway.security.redact import redact
__all__ = ["Processes", "subtree"]
_VALUE_HIDDEN = frozenset(
{
"--system-prompt",
"--append-system-prompt",
"--mcp-config",
"--settings",
"--agents",
}
)
_ARG_CLIP = 80
_COMMAND_CLIP = 240
@dataclass(slots=True)
class Processes:
root: int = field(default_factory=os.getpid)
_cpu_seen: dict[int, tuple[float, float]] = field(default_factory=dict)
def tree_parents_first(self) -> list[dict[str, Any]]:
try:
found = psutil.Process(self.root).children(recursive=True)
except psutil.Error:
return []
now = time.monotonic()
rows: dict[int, dict[str, Any]] = {}
for process in found:
with contextlib.suppress(psutil.Error), process.oneshot():
spent = process.cpu_times()
rows[process.pid] = {
"pid": process.pid,
"parent": process.ppid(),
"name": process.name(),
"command": _command(process.cmdline()),
"rss": process.memory_info().rss,
"cpu": self._rate(process.pid, spent.user + spent.system, now),
"age_seconds": round(time.time() - process.create_time(), 1),
}
self._cpu_seen = {p: seen for p, seen in self._cpu_seen.items() if p in rows}
children: dict[int, list[int]] = {}
for pid, row in rows.items():
parent = row["parent"] if row["parent"] in rows else self.root
children.setdefault(parent, []).append(pid)
ordered: list[dict[str, Any]] = []
def walk(parent: int, depth: int) -> None:
for pid in sorted(children.get(parent, ())):
ordered.append({**rows[pid], "depth": depth})
walk(pid, depth + 1)
walk(self.root, 0)
return ordered
def kill_own_descendant(self, pid: int) -> bool:
try:
owned = psutil.Process(self.root).children(recursive=True)
target = next((p for p in owned if p.pid == pid), None)
if target is None:
return False
doomed = [*target.children(recursive=True), target]
except psutil.Error:
return False
for process in doomed:
with contextlib.suppress(psutil.Error):
process.kill()
return True
def _rate(self, pid: int, spent: float, now: float) -> float | None:
before = self._cpu_seen.get(pid)
self._cpu_seen[pid] = (spent, now)
if before is None or now <= before[1]:
return None
return round(100 * max(spent - before[0], 0.0) / (now - before[1]), 1)
def subtree(rows: list[dict[str, Any]], pid: int | None) -> list[dict[str, Any]]:
start = next((i for i, row in enumerate(rows) if row["pid"] == pid), None)
if start is None:
return []
base = rows[start]["depth"]
out = [{**rows[start], "depth": 0}]
for row in rows[start + 1 :]:
if row["depth"] <= base:
break
out.append({**row, "depth": row["depth"] - base})
return out
def _command(argv: list[str]) -> str:
if not argv:
return ""
parts = [Path(argv[0]).name]
hide = False
for arg in argv[1:]:
if hide:
parts.append("…")
hide = False
continue
hide = arg in _VALUE_HIDDEN
parts.append(arg if len(arg) <= _ARG_CLIP else f"{arg[:_ARG_CLIP]}…")
command = redact(" ".join(parts))
return command if len(command) <= _COMMAND_CLIP else f"{command[:_COMMAND_CLIP]}…"
+66 -33
View File
@@ -13,12 +13,15 @@ import contextlib
import logging
import os
import time
from collections import deque
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any, Protocol
import psutil
from beaver_gateway.backends.processes import Processes, subtree
if TYPE_CHECKING:
from collections.abc import (
AsyncIterable,
@@ -43,6 +46,7 @@ DEFAULT_TTL: Mapping[str, float | None] = {
_EVICT_ORDER = {"fork": 0, "job": 0, "deep": 1, "branch": 2, "master": 3}
_RSS_HEADROOM = 0.8
STDERR_LINES = 200
class SessionClient(Protocol):
@@ -86,6 +90,7 @@ class Session:
"""Summary of the last task notification, the pending turn's prompt."""
observer: Callable[[Any], None] | None = None
"""The last turn's observer; sees subagent traffic between turns."""
stderr: deque[str] = field(default_factory=lambda: deque(maxlen=STDERR_LINES))
@property
def busy(self) -> bool:
@@ -121,6 +126,7 @@ class SessionPool:
self._max_live = max_live
self._ttl = dict(ttl)
self._reap_interval = reap_interval
self._processes = Processes()
def __len__(self) -> int:
return len(self._sessions)
@@ -168,19 +174,6 @@ class SessionPool:
total += child.memory_info().rss
return total
@staticmethod
def rss_of(session: Session) -> int | None:
pid = session.pid
if pid is None:
return None
try:
process = psutil.Process(pid)
return process.memory_info().rss + sum(
c.memory_info().rss for c in process.children(recursive=True)
)
except psutil.Error:
return None
def over_limit(self) -> bool:
if self._rss_limit is not None:
return self.rss() > self._rss_limit * _RSS_HEADROOM
@@ -214,11 +207,21 @@ class SessionPool:
_log.info("closing session %s (%s, %s)", session.session_id, session.kind, key)
if session.reader is not None and session.reader is not asyncio.current_task():
session.reader.cancel()
if session.attached:
session.inbox.put_nowait(RuntimeError(f"session {key} was closed"))
try:
await session.client.disconnect()
except Exception: # noqa: BLE001
_log.exception("disconnect failed for session %s", session.session_id)
async def interrupt(self, key: str) -> bool:
session = self._sessions.get(key)
if session is None or not session.busy:
return False
session.interrupt_requested = True
await session.client.interrupt()
return True
async def close_all(self, *, agent: str | None = None) -> None:
for session in list(self._sessions.values()):
if agent is None or session.agent == agent:
@@ -246,27 +249,57 @@ class SessionPool:
_log.exception("session reaper failed")
def snapshot(self) -> list[dict[str, Any]]:
return self.census()["sessions"]
def census(self) -> dict[str, Any]:
now = time.monotonic()
return [
{
"key": s.key,
"agent": s.agent,
"kind": s.kind,
"session_id": s.session_id,
"pid": s.pid,
"rss": self.rss_of(s),
"idle_seconds": round(now - s.last_used, 1),
"age_seconds": round(now - s.created_at, 1),
"turns": s.turns,
"busy": s.busy,
"running_turn": s.running_turn,
"pending": len(s.pending),
"pending_question": s.pending_question,
"pinned": s.pinned,
"dirty": s.dirty,
}
for s in self._sessions.values()
]
rows = self._processes.tree_parents_first()
owned: set[int] = set()
sessions: list[dict[str, Any]] = []
for s in self._sessions.values():
tree = subtree(rows, s.pid)
owned.update(row["pid"] for row in tree)
sessions.append(
{
"key": s.key,
"agent": s.agent,
"kind": s.kind,
"session_id": s.session_id,
"pid": s.pid,
"rss": sum(row["rss"] for row in tree) if tree else None,
"cpu": _cpu(tree),
"idle_seconds": round(now - s.last_used, 1),
"age_seconds": round(now - s.created_at, 1),
"turns": s.turns,
"busy": s.busy,
"running_turn": s.running_turn,
"pending": len(s.pending),
"pending_question": s.pending_question,
"pinned": s.pinned,
"dirty": s.dirty,
"processes": tree,
}
)
return {
"rss": sum(row["rss"] for row in rows),
"rss_limit": self._rss_limit,
"cpu": _cpu(rows),
"sessions": sessions,
"others": [row for row in rows if row["pid"] not in owned],
}
async def kill(self, pid: int) -> bool:
if not self._processes.kill_own_descendant(pid):
return False
for session in list(self._sessions.values()):
if session.pid == pid:
await self.close(session.key)
return True
def _cpu(rows: list[dict[str, Any]]) -> float | None:
rates = [row["cpu"] for row in rows if row["cpu"] is not None]
return round(sum(rates), 1) if rates else None
def cgroup_limit() -> int | None:
+46 -9
View File
@@ -10,7 +10,7 @@ import hashlib
import uuid as _uuid
from collections.abc import Mapping
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, cast
try:
from claude_agent_sdk._cli_version import __cli_version__
@@ -31,6 +31,8 @@ __all__ = [
"open_tool_uses",
"prompt_count",
"render_messages",
"reply_text",
"spoken_text",
"strip_tool_entries",
"text_of",
"window_entries",
@@ -162,9 +164,9 @@ def build_entries(
def messages_from_entries(
entries: Iterable[Mapping[str, Any]], *, with_ts: bool = False
entries: Iterable[Mapping[str, Any]], *, stamped: bool = False
) -> list[dict[str, Any]]:
"""Messages as the model saw them; ``with_ts`` adds when each one was written."""
"""Messages as the model saw them; ``stamped`` adds each one's entry id and time."""
out: list[dict[str, Any]] = []
last_message_id: str | None = None
for entry in entries:
@@ -187,7 +189,8 @@ def messages_from_entries(
"role": "user",
"content": _user_content(content),
}
if with_ts:
if stamped:
message_out["id"] = entry.get("uuid")
message_out["ts"] = entry.get("timestamp")
out.append(message_out)
last_message_id = None
@@ -203,7 +206,8 @@ def messages_from_entries(
out[-1]["content"].extend(blocks)
else:
message_out: dict[str, Any] = {"role": "assistant", "content": blocks}
if with_ts:
if stamped:
message_out["id"] = entry.get("uuid")
message_out["ts"] = entry.get("timestamp")
out.append(message_out)
last_message_id = message_id
@@ -434,13 +438,45 @@ def prompt_count(entries: Iterable[Mapping[str, Any]]) -> int:
return sum(1 for e in entries if _is_prompt_entry(e))
def spoken_text(block: Any, *, thinking_is_progress: bool) -> str:
"""What a content block (dict or model) says to the human.
Text always; thinking only when the agent's thinking is progress
written for the human, never when it is reasoning.
"""
if isinstance(block, Mapping):
fields = cast("Mapping[str, Any]", block)
kind, text, thinking = (
fields.get("type"),
fields.get("text"),
fields.get("thinking"),
)
else:
kind = getattr(block, "type", None)
text, thinking = getattr(block, "text", None), getattr(block, "thinking", None)
if kind == "text":
return str(text or "").strip()
if kind == "thinking" and thinking_is_progress:
return str(thinking or "").strip()
return ""
def reply_text(content: Iterable[Any], *, thinking_is_progress: bool) -> str:
"""The reply as the human reads it: spoken blocks in order, a blank line apart."""
parts = (spoken_text(b, thinking_is_progress=thinking_is_progress) for b in content)
return "\n\n".join(p for p in parts if p)
def render_messages(
messages: Iterable[Mapping[str, Any]], *, window: int | None = None
messages: Iterable[Mapping[str, Any]],
*,
thinking_is_progress: bool,
window: int | None = None,
) -> str:
"""Plain-text projection for ``read_conversation``: ``user:``/``assistant:`` turns.
Tool calls collapse to a one-line summary per assistant turn; tool
results and thinking are dropped.
results and reasoning are dropped, progress thinking reads as text.
"""
turns: list[str] = []
tools: list[str] = []
@@ -466,8 +502,9 @@ def render_messages(
turns.append("user:\n" + _text_of_content(content))
continue
for block in _assistant_blocks(content):
if block.get("type") == "text" and block.get("text"):
current.append(str(block["text"]))
text = spoken_text(block, thinking_is_progress=thinking_is_progress)
if text:
current.append(text)
elif block.get("type") == "tool_use":
tools.append(str(block.get("name", "")))
flush()
+34 -3
View File
@@ -19,6 +19,7 @@ from beaver_gateway.backends.transcript import (
)
from beaver_gateway.conversations.kinds import KINDS, Kind
from beaver_gateway.conversations.state import State, iso
from beaver_gateway.conversations.texts import split_service
from beaver_gateway.frontends.markdown.history import load_messages
from beaver_gateway.storage.models import (
Conversation,
@@ -352,6 +353,8 @@ class Rows(State):
for b in await self.bindings(conv)
]
live = self._pool.get(conv.external_id)
agent = self._agents.get(conv.agent_name)
out["thinking_is_progress"] = agent is not None and agent.thinking_is_progress
out["live"] = live is not None
out["busy"] = live.busy if live is not None else False
runner = self._runners.get(cast("int", conv.id))
@@ -466,7 +469,13 @@ class Rows(State):
return marked
async def read(self, conv: Conversation, *, window: int | None = None) -> str:
return render_messages(await self.history(conv), window=window)
return render_messages(
await self.history(conv),
thinking_is_progress=self._claude_agent(
conv.agent_name
).thinking_is_progress,
window=window,
)
async def history(self, conv: Conversation) -> list[dict[str, Any]]:
if conv.session_id is None:
@@ -474,9 +483,31 @@ class Rows(State):
return await load_messages(
session, conversation_id=cast("int", conv.id)
)
return messages_from_entries(
cast("Any", await self.entries(conv)), with_ts=True
messages = messages_from_entries(
cast("Any", await self.entries(conv)), stamped=True
)
return [self._unwrapped(message) for message in messages]
def _unwrapped(self, message: dict[str, Any]) -> dict[str, Any]:
if message["role"] != "user":
return message
content = message["content"]
if isinstance(content, str):
text, service = split_service(content, self._texts)
return (
message
if service is None
else {**message, "content": text, "service": service}
)
for index, block in enumerate(content):
if block.get("type") != "text":
continue
text, service = split_service(str(block.get("text") or ""), self._texts)
if service is not None:
blocks = [*content]
blocks[index] = {**block, "text": text}
return {**message, "content": blocks, "service": service}
return message
async def entries(self, conv: Conversation, *, subpath: str = "") -> list[Any]:
if conv.session_id is None:
+11
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
@@ -22,6 +23,7 @@ __all__ = [
"NewDayContext",
"SeedContext",
"UserSaid",
"split_service",
]
@@ -127,3 +129,12 @@ class ConversationTexts:
"; {fixed} open tool calls received tool_result «{interrupted}»"
)
envelope: EnvelopeTexts = field(default_factory=EnvelopeTexts)
def split_service(text: str, texts: ConversationTexts) -> tuple[str, str | None]:
bundle = re.escape(texts.bundle_header).replace(r"\{since\}", ".+")
heads = f"{re.escape(texts.envelope.header)}|{bundle}"
match = re.search(rf"(?:\A|\n\n)(?=(?:{heads})$)", text, re.MULTILINE)
if match is None:
return text, None
return text[: match.start()], text[match.end() :]
+4 -7
View File
@@ -23,7 +23,7 @@ from claude_agent_sdk import (
)
from beaver_gateway.backends.capture import TurnCapture
from beaver_gateway.backends.transcript import text_of
from beaver_gateway.backends.transcript import reply_text, text_of
from beaver_gateway.conversations import injects
from beaver_gateway.conversations.seeds import Seeds
from beaver_gateway.conversations.state import Runner, aware, iso
@@ -164,12 +164,9 @@ class Turns(Seeds):
):
acc.feed(event)
message = acc.finalize(model=agent.model)
reply = "\n\n".join(
getattr(b, "text", "")
for b in message.content
if getattr(b, "type", "") == "text"
).strip()
return reply, capture
return reply_text(
message.content, thinking_is_progress=agent.thinking_is_progress
), capture
async def before_turn(self, conv: Conversation) -> str | None: # noqa: ARG002
return None
+7 -1
View File
@@ -57,10 +57,16 @@ class EventBus:
event = await queue.get()
if (
conversation_id is None
or event.get("conversation_id") == conversation_id
or _conversation_of(event) == conversation_id
):
yield event
@property
def subscribers(self) -> int:
return len(self._subscribers)
def _conversation_of(event: Event) -> Any:
if event["type"].startswith("conversation."):
return event.get("id")
return event.get("conversation_id")
+9 -9
View File
@@ -17,7 +17,7 @@ from fastapi.responses import JSONResponse, StreamingResponse
from beaver_gateway.agents.claude import ClaudeAgent
from beaver_gateway.backends.capture import TurnCapture
from beaver_gateway.backends.transcript import fingerprint, text_of
from beaver_gateway.backends.transcript import fingerprint, reply_text, text_of
from beaver_gateway.frontends.accumulate import StreamAccumulator
from beaver_gateway.frontends.base import Frontend
from beaver_gateway.frontends.bearer import require_token
@@ -29,6 +29,7 @@ if TYPE_CHECKING:
from anthropic.types import Message, MessageParam
from beaver_gateway.agents.base import BaseAgent
from beaver_gateway.conversations.service import Conversations
from beaver_gateway.events.stream import MessageStreamEvent
from beaver_gateway.frontends.base import GatewayRuntime
@@ -158,6 +159,7 @@ class AnthropicMessagesFrontend(Frontend):
capture=capture,
turn_id=turn_id,
history=history,
thinking_is_progress=agent.thinking_is_progress,
)
else:
options: dict[str, Any] = {}
@@ -170,7 +172,7 @@ class AnthropicMessagesFrontend(Frontend):
async def after(message: Message) -> None:
await _broadcast_turn(
runtime,
agent_name=agent.name,
agent=agent,
input_messages=messages,
output_message=message,
system=system_str,
@@ -235,12 +237,9 @@ async def _finish(
capture: TurnCapture,
turn_id: str,
history: list[dict[str, str]] | None,
thinking_is_progress: bool,
) -> None:
reply = "\n\n".join(
getattr(b, "text", "")
for b in message.content
if getattr(b, "type", "") == "text"
).strip()
reply = reply_text(message.content, thinking_is_progress=thinking_is_progress)
tail = capture.synthesized_messages or [
{"role": "assistant", "content": [{"type": "text", "text": reply}]}
]
@@ -291,7 +290,7 @@ async def _sse(
async def _broadcast_turn(
runtime: GatewayRuntime,
*,
agent_name: str,
agent: BaseAgent,
input_messages: list[MessageParam],
output_message: Message,
system: str | None,
@@ -299,9 +298,10 @@ async def _broadcast_turn(
if not runtime.turn_log_handlers:
return
record = TurnRecord(
agent_name=agent_name,
agent_name=agent.name,
input_messages=list(input_messages),
output_message=output_message,
thinking_is_progress=agent.thinking_is_progress,
system=system,
source=FRONTEND,
)
+66 -6
View File
@@ -55,6 +55,7 @@ if TYPE_CHECKING:
from collections.abc import AsyncIterator, Iterable, Sequence
from pathlib import Path
from beaver_gateway.backends.sessions import Session, SessionPool
from beaver_gateway.conversations.service import Conversations
from beaver_gateway.frontends.base import GatewayRuntime
from beaver_gateway.jobs.scheduler import Job, Scheduler
@@ -633,15 +634,74 @@ def build_app( # noqa: PLR0915
await require_token(request, runtime, scope=SCOPE)
return _sse(runtime, conversation_id=None)
def pool_of() -> SessionPool:
if runtime.pool is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "no session pool")
return runtime.pool
def session_of(key: str) -> Session:
session = pool_of().get(key)
if session is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, f"no live session {key}")
return session
@app.get("/sessions")
async def sessions(request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=SCOPE)
pool = runtime.pool
return {
"rss": pool.rss() if pool is not None else None,
"rss_limit": pool.rss_limit if pool is not None else None,
"sessions": pool.snapshot() if pool is not None else [],
}
if runtime.pool is None:
return {
"rss": None,
"rss_limit": None,
"cpu": None,
"sessions": [],
"others": [],
}
return runtime.pool.census()
@app.get("/sessions/{key}/stderr")
async def session_stderr(key: str, request: Request) -> dict[str, Any]:
await require_token(request, runtime, scope=ADMIN_SCOPE)
return {"key": key, "lines": list(session_of(key).stderr)}
@app.post("/sessions/{key}/interrupt")
async def session_interrupt(key: str, request: Request) -> dict[str, Any]:
token = await require_token(request, runtime, scope=SCOPE)
session = session_of(key)
interrupted = await pool_of().interrupt(key)
await audit.log(
runtime,
actor=f"token:{token}",
kind="session_interrupt",
agent_name=session.agent,
session=key,
interrupted=interrupted,
)
return {"key": key, "interrupted": interrupted}
@app.post("/sessions/{key}/close")
async def session_close(key: str, request: Request) -> dict[str, Any]:
token = await require_token(request, runtime, scope=ADMIN_SCOPE)
session = session_of(key)
await pool_of().close(key)
await audit.log(
runtime,
actor=f"token:{token}",
kind="session_close",
agent_name=session.agent,
session=key,
busy=session.busy,
)
return {"key": key, "closed": True}
@app.post("/processes/{pid}/kill")
async def process_kill(pid: int, request: Request) -> dict[str, Any]:
token = await require_token(request, runtime, scope=ADMIN_SCOPE)
if not await pool_of().kill(pid):
raise HTTPException(
status.HTTP_404_NOT_FOUND, f"no process {pid} under the gateway"
)
await audit.log(runtime, actor=f"token:{token}", kind="process_kill", pid=pid)
return {"pid": pid, "killed": True}
@app.get("/schedules")
async def schedules(request: Request) -> dict[str, Any]:
@@ -142,14 +142,16 @@ class CrossFrontendLogger:
new_user = record.input_messages[-1]
new_block = renderer.render_user_param(new_user)
new_block = renderer.append_to_body(
new_block, renderer.render_assistant_message(record.output_message)
new_block,
renderer.render_assistant_message(
record.output_message,
thinking_is_progress=record.thinking_is_progress,
),
)
new_body = renderer.append_to_body(body, new_block)
metadata = dict(parsed.metadata)
else:
new_body = _render_full_history(
record.input_messages, record.output_message
)
new_body = _render_full_history(record)
metadata = {}
new_body = renderer.append_to_body(new_body, renderer.USER_SCAFFOLD)
@@ -204,10 +206,10 @@ def _flatten_text(message: Any) -> str:
return "\n\n".join(c for c in chunks if c)
def _render_full_history(messages: list[MessageParam], assistant: Any) -> str:
def _render_full_history(record: TurnRecord) -> str:
"""Render an entire conversation (used when materializing a new logged file)."""
blocks: list[str] = []
for msg in messages:
for msg in record.input_messages:
role = msg.get("role")
if role == "user":
blocks.append(renderer.render_user_param(msg))
@@ -215,7 +217,11 @@ def _render_full_history(messages: list[MessageParam], assistant: Any) -> str:
content = msg.get("content", "")
text = content if isinstance(content, str) else _content_to_text(content)
blocks.append(f"### Assistant:\n\n{text.strip()}\n" if text.strip() else "")
blocks.append(renderer.render_assistant_message(assistant))
blocks.append(
renderer.render_assistant_message(
record.output_message, thinking_is_progress=record.thinking_is_progress
)
)
body = ""
for block in blocks:
if not block:
@@ -341,7 +341,11 @@ class MarkdownFrontend(Frontend):
file_path=file_path,
first_user_text=parsed.turns[0].text,
)
outcome = diff_and_fork(stored=stored_msgs, incoming=parsed.turns)
outcome = diff_and_fork(
stored=stored_msgs,
incoming=parsed.turns,
thinking_is_progress=agent.thinking_is_progress,
)
capture = TurnCapture()
events = runtime.conversations.turn(
conv,
@@ -356,6 +360,7 @@ class MarkdownFrontend(Frontend):
file_path=file_path,
parsed=parsed,
model=agent.model or agent.name,
thinking_is_progress=agent.thinking_is_progress,
filename=filename,
write_disk=write_disk,
)
@@ -371,6 +376,7 @@ class MarkdownFrontend(Frontend):
parsed=parsed,
message=message,
agent_name=agent.name,
thinking_is_progress=agent.thinking_is_progress,
conv_external_id=conv_external_id,
write_disk=write_disk,
)
@@ -388,6 +394,7 @@ class MarkdownFrontend(Frontend):
agent_name=agent.name,
input_messages=list(parsed.messages),
output_message=message,
thinking_is_progress=agent.thinking_is_progress,
system=None,
source="markdown",
)
@@ -534,7 +541,11 @@ class MarkdownFrontend(Frontend):
len(stored_msgs),
len(parsed.turns),
)
outcome = diff_and_fork(stored=stored_msgs, incoming=parsed.turns)
outcome = diff_and_fork(
stored=stored_msgs,
incoming=parsed.turns,
thinking_is_progress=agent.thinking_is_progress,
)
_log.info(
"chat/stream: file=%s diff_and_fork divergence=%s "
"backend_msgs=%d persist_msgs=%d",
@@ -567,7 +578,9 @@ class MarkdownFrontend(Frontend):
partial = acc.finalize(model=model)
if not partial.content:
return None
rendered = renderer.render_assistant_message(partial)
rendered = renderer.render_assistant_message(
partial, thinking_is_progress=agent.thinking_is_progress
)
new_body = renderer.append_to_body(parsed.body, rendered)
return reattach_frontmatter(parsed.metadata, new_body)
@@ -593,7 +606,10 @@ class MarkdownFrontend(Frontend):
new_body = parsed.body
if partial.content:
new_body = renderer.append_to_body(
new_body, renderer.render_assistant_message(partial)
new_body,
renderer.render_assistant_message(
partial, thinking_is_progress=agent.thinking_is_progress
),
)
new_body = renderer.append_to_body(new_body, _render_error_block(exc))
if write_disk:
@@ -616,6 +632,7 @@ class MarkdownFrontend(Frontend):
parsed=parsed,
message=message,
agent_name=agent.name,
thinking_is_progress=agent.thinking_is_progress,
conv_external_id=conv_external_id,
write_disk=write_disk,
)
@@ -633,6 +650,7 @@ class MarkdownFrontend(Frontend):
agent_name=agent.name,
input_messages=list(parsed.messages),
output_message=message,
thinking_is_progress=agent.thinking_is_progress,
system=None,
source="markdown",
)
@@ -659,6 +677,7 @@ class MarkdownFrontend(Frontend):
file_path: Path,
parsed: parser.ParsedFile,
model: str,
thinking_is_progress: bool,
filename: str,
write_disk: bool = True,
) -> Any:
@@ -675,7 +694,9 @@ class MarkdownFrontend(Frontend):
partial = acc.finalize(model=model)
if not partial.content:
return
rendered = renderer.render_assistant_message(partial)
rendered = renderer.render_assistant_message(
partial, thinking_is_progress=thinking_is_progress
)
new_body = renderer.append_to_body(parsed.body, rendered)
await write_atomic(
file_path, reattach_frontmatter(parsed.metadata, new_body)
@@ -698,7 +719,10 @@ class MarkdownFrontend(Frontend):
new_body = parsed.body
if partial.content:
new_body = renderer.append_to_body(
new_body, renderer.render_assistant_message(partial)
new_body,
renderer.render_assistant_message(
partial, thinking_is_progress=thinking_is_progress
),
)
new_body = renderer.append_to_body(new_body, _render_error_block(exc))
if write_disk:
@@ -715,11 +739,14 @@ class MarkdownFrontend(Frontend):
parsed: parser.ParsedFile,
message: Any,
agent_name: str,
thinking_is_progress: bool,
conv_external_id: str,
write_disk: bool = True,
) -> str:
"""Render the assistant turn, refresh frontmatter, write if we own the file."""
rendered = renderer.render_assistant_message(message)
rendered = renderer.render_assistant_message(
message, thinking_is_progress=thinking_is_progress
)
new_body = renderer.append_to_body(parsed.body, rendered)
new_body = renderer.append_to_body(new_body, renderer.USER_SCAFFOLD)
updated_metadata = dict(parsed.metadata)
@@ -14,6 +14,7 @@ from typing import TYPE_CHECKING, Any, cast
from sqlalchemy import delete
from sqlmodel import select
from beaver_gateway.backends.transcript import spoken_text
from beaver_gateway.storage.models import Conversation, ConversationMessage
if TYPE_CHECKING:
@@ -180,7 +181,9 @@ class _StoredDisplayTurn:
text_segment_count: int
def _group_display_turns(stored: list[dict[str, Any]]) -> list[_StoredDisplayTurn]:
def _group_display_turns(
stored: list[dict[str, Any]], *, thinking_is_progress: bool
) -> list[_StoredDisplayTurn]:
"""Walk raw stored messages, group them into Obsidian-visible turns.
A user-prompt message (no ``tool_result`` blocks) opens a new turn;
@@ -210,7 +213,9 @@ def _group_display_turns(stored: list[dict[str, Any]]) -> list[_StoredDisplayTur
break
group.append(m)
i += 1
spoken, skeleton, text_count = _summarize_assistant_group(group)
spoken, skeleton, text_count = _summarize_assistant_group(
group, thinking_is_progress=thinking_is_progress
)
out.append(
_StoredDisplayTurn(
role="assistant",
@@ -251,12 +256,13 @@ def _user_prompt_text(content: Any) -> str:
def _summarize_assistant_group(
group: list[dict[str, Any]],
group: list[dict[str, Any]], *, thinking_is_progress: bool
) -> tuple[str, list[str], int]:
"""Compute (spoken_text, tool_skeleton, text_segment_count) for a display group.
Must mirror ``parser.parse_assistant_structure``: text blocks collapse into
one segment, tool_use becomes a skeleton entry, tool_result/thinking are invisible.
Must mirror what the renderer writes and the parser reads back: text and
progress thinking collapse into one segment, tool_use becomes a skeleton
entry, tool_result, reasoning and empty thinking are invisible.
"""
from beaver_gateway.frontends.markdown.parser import TextSegment, ToolSegment
@@ -282,12 +288,10 @@ def _summarize_assistant_group(
for blk in content:
if not isinstance(blk, dict):
continue
btype = blk.get("type")
if btype == "text":
text = str(blk.get("text", "")).strip()
if text:
pending.append(text)
elif btype == "tool_use":
text = spoken_text(blk, thinking_is_progress=thinking_is_progress)
if text:
pending.append(text)
elif blk.get("type") == "tool_use":
_flush()
segments.append(ToolSegment(name=str(blk.get("name", ""))))
_flush()
@@ -299,7 +303,10 @@ def _summarize_assistant_group(
def diff_and_fork(
*, stored: list[dict[str, Any]], incoming: list[ParsedTurn]
*,
stored: list[dict[str, Any]],
incoming: list[ParsedTurn],
thinking_is_progress: bool,
) -> ForkOutcome:
"""Align the incoming parsed file against stored history.
@@ -314,11 +321,15 @@ def diff_and_fork(
)
raise ValueError(msg)
stored_groups = _group_display_turns(stored)
stored_groups = _group_display_turns(
stored, thinking_is_progress=thinking_is_progress
)
new_user_turn = incoming[-1]
prior_incoming = incoming[:-1]
spliced_groups, divergence, edited = _walk_prefix(prior_incoming, stored_groups)
spliced_groups, divergence, edited = _walk_prefix(
prior_incoming, stored_groups, thinking_is_progress=thinking_is_progress
)
if divergence is None and len(prior_incoming) < len(stored_groups):
if _file_lags_store(stored_groups, len(prior_incoming), new_user_turn):
@@ -358,7 +369,10 @@ def _file_lags_store(
def _walk_prefix(
prior_incoming: list[ParsedTurn], stored_groups: list[_StoredDisplayTurn]
prior_incoming: list[ParsedTurn],
stored_groups: list[_StoredDisplayTurn],
*,
thinking_is_progress: bool,
) -> tuple[list[list[dict[str, Any]]], int | None, bool]:
"""Walk incoming vs stored side-by-side until first divergence.
@@ -391,7 +405,9 @@ def _walk_prefix(
continue
if inc_skeleton and inc_text_count != st.text_segment_count:
return spliced_groups, i, edited
spliced = _splice_assistant_group(stored_group=st, incoming=inc)
spliced = _splice_assistant_group(
stored_group=st, incoming=inc, thinking_is_progress=thinking_is_progress
)
if spliced is None:
return spliced_groups, i, edited
spliced_groups.append(spliced)
@@ -426,7 +442,10 @@ def _assemble_tail(
def _splice_assistant_group(
*, stored_group: _StoredDisplayTurn, incoming: ParsedTurn
*,
stored_group: _StoredDisplayTurn,
incoming: ParsedTurn,
thinking_is_progress: bool,
) -> list[dict[str, Any]] | None:
"""Apply the file's prose to a stored assistant turn.
@@ -434,27 +453,28 @@ def _splice_assistant_group(
markdown can't express. Falls back to rebuilding from the file's
structure when the text blocks don't line up one-to-one.
"""
preserved = _splice_in_place(stored_group=stored_group, incoming=incoming)
preserved = _splice_in_place(
stored_group=stored_group,
incoming=incoming,
thinking_is_progress=thinking_is_progress,
)
if preserved is not None:
return preserved
return _splice_by_rebuild(stored_group=stored_group, incoming=incoming)
def _is_text_block(blk: object) -> bool:
if not isinstance(blk, dict):
return False
block = cast("dict[str, Any]", blk)
return block.get("type") == "text" and bool(str(block.get("text", "")).strip())
def _splice_in_place(
*, stored_group: _StoredDisplayTurn, incoming: ParsedTurn
*,
stored_group: _StoredDisplayTurn,
incoming: ParsedTurn,
thinking_is_progress: bool,
) -> list[dict[str, Any]] | None:
"""Copy the stored messages, swapping only their text block contents.
Preserves message/thinking-block boundaries that a rebuild would lose
(and that a cache-hit continuation depends on); returns ``None`` if
text blocks aren't 1:1 with incoming segments, so the caller rebuilds instead.
text blocks aren't 1:1 with incoming segments or the prose runs through
signed thinking blocks, so the caller rebuilds instead.
"""
from beaver_gateway.frontends.markdown.parser import TextSegment
@@ -466,9 +486,12 @@ def _splice_in_place(
content = msg.get("content")
if msg["role"] != "assistant" or not isinstance(content, list):
continue
positions.extend(
(mi, bi) for bi, blk in enumerate(content) if _is_text_block(blk)
)
for bi, blk in enumerate(cast("list[dict[str, Any]]", content)):
if not spoken_text(blk, thinking_is_progress=thinking_is_progress):
continue
if blk["type"] == "thinking":
return None
positions.append((mi, bi))
if len(positions) != len(new_texts):
return None
@@ -12,8 +12,6 @@ from typing import TYPE_CHECKING, Any, cast
from anthropic.types import Message, TextBlock, ThinkingBlock
if TYPE_CHECKING:
from collections.abc import Iterable
from anthropic.types import MessageParam
@@ -45,24 +43,25 @@ def render_assistant_text(text: str) -> str:
return f"### Assistant:\n\n{text.strip()}\n"
def render_assistant_message(message: Message) -> str:
def render_assistant_message(message: Message, *, thinking_is_progress: bool) -> str:
"""Render an assistant ``Message`` into a turn block.
Tool-use blocks render to nothing; of the thinking blocks between two
texts only the first one reaches the file.
Tool-use blocks render to nothing. Progress thinking renders as text in
place and vanishes when empty; reasoning renders as a collapsed callout,
of which only the first between two texts reaches the file.
"""
parts: list[str] = ["### Assistant:", ""]
after_thinking = False
after_reasoning = False
for block in message.content:
thinking = isinstance(block, ThinkingBlock)
if thinking and after_thinking:
if isinstance(block, ThinkingBlock) and not thinking_is_progress:
if not after_reasoning:
parts.extend((*_render_reasoning(block.thinking or ""), ""))
after_reasoning = True
continue
lines = list(_render_block(block))
if not lines:
continue
after_thinking = thinking
parts.extend(lines)
parts.append("")
text = _spoken(block)
if text:
parts.extend((text, ""))
after_reasoning = False
return "\n".join(parts).rstrip() + "\n"
@@ -108,21 +107,17 @@ def summarize_tool_input(name: str, tool_input: object) -> str:
return name
def _render_block(block: object) -> Iterable[str]:
def _render_reasoning(text: str) -> tuple[str, ...]:
lines = text.strip().splitlines() or [""]
return ("> [!thinking]-", *(f"> {line}" if line else ">" for line in lines))
def _spoken(block: object) -> str:
if isinstance(block, TextBlock):
text = (block.text or "").strip()
if text:
yield text
return
return (block.text or "").strip()
if isinstance(block, ThinkingBlock):
yield from _render_thinking(block.thinking or "")
return
def _render_thinking(text: str) -> Iterable[str]:
yield "> [!thinking]-"
for line in text.strip().splitlines() or [""]:
yield f"> {line}" if line else ">"
return (block.thinking or "").strip()
return ""
def adaptive_fence(content: str) -> str:
@@ -9,6 +9,7 @@ from __future__ import annotations
import asyncio
import contextlib
import html
import logging
import time
import zlib
@@ -26,6 +27,7 @@ __all__ = ["Draft"]
_log = logging.getLogger("beaver_gateway.frontends.telegram.drafts")
_TAIL = 3500
_REASONING_TAIL = 400
_KEEPALIVE = 20.0
@@ -47,6 +49,7 @@ class Draft:
self._interval = interval
self.status = status
self.text = ""
self.reasoning = ""
self._paused = False
self._dirty = True
self._broken = False
@@ -80,9 +83,20 @@ class Draft:
self.text = self.text.rstrip() + "\n\n" + text.lstrip()
else:
self.text += text
self.reasoning = ""
self._paused = False
self._dirty = True
def think(self, text: str) -> None:
if text:
self.reasoning += text
self._dirty = True
def rethink(self) -> None:
if self.reasoning:
self.reasoning = ""
self._dirty = True
async def stop(self) -> None:
"""Stop pushing and wait for the push in flight.
@@ -146,8 +160,21 @@ class Draft:
)
def _render(self) -> tuple[str, str]:
rendered, plain = [to_html(self.status)], [self.status]
thought = _tail(self.reasoning.strip(), _REASONING_TAIL)
if thought:
rendered.append(f"<blockquote>{html.escape(thought)}</blockquote>")
plain.append(thought)
tail = self.text[-_TAIL:]
if not tail.strip():
return to_html(self.status), self.status
rendered = f"{to_html(self.status)}\n\n{to_html_tail(self.text, _TAIL)}"
return rendered, f"{self.status}\n\n{tail}"
if tail.strip():
rendered.append(to_html_tail(self.text, _TAIL))
plain.append(tail)
return "\n\n".join(rendered), "\n\n".join(plain)
def _tail(text: str, limit: int) -> str:
if len(text) <= limit:
return text
cut = text[-limit:]
_, _, rest = cut.partition(" ")
return f"… {rest or cut}"
@@ -852,7 +852,10 @@ class TelegramFrontend(Frontend):
if event.get("origin") in ("user", "task"):
await self._open_draft(key, event, target)
case "stream":
self._feed_draft(key, event)
agents = cast("GatewayRuntime", self._runtime).agents
agent = agents.get(conv.agent_name)
progress = agent is not None and agent.thinking_is_progress
self._feed_draft(key, event, thinking_is_progress=progress)
case "tool":
draft = self._drafts.get(key)
if draft is not None and event.get("parent_tool_use_id") is None:
@@ -971,7 +974,9 @@ class TelegramFrontend(Frontend):
self._drafts[key] = draft
draft.start()
def _feed_draft(self, key: str, event: Event) -> None:
def _feed_draft(
self, key: str, event: Event, *, thinking_is_progress: bool
) -> None:
draft = self._drafts.get(key)
if draft is None or event.get("parent_tool_use_id") is not None:
return
@@ -983,11 +988,19 @@ class TelegramFrontend(Frontend):
draft.set_status(self.texts.writing)
draft.append(str(delta.get("text") or ""))
elif delta.get("type") == "thinking_delta":
draft.set_status(self.texts.thinking)
progress = str(delta.get("thinking") or "")
if progress and thinking_is_progress:
draft.set_status(self.texts.writing)
draft.append(progress)
else:
draft.set_status(self.texts.thinking)
draft.think(progress)
elif kind == "content_block_start":
block = raw.get("content_block") or {}
if block.get("type") == "tool_use":
draft.set_status(self._tool_status(block.get("name") or "", None))
elif block.get("type") == "thinking":
draft.rethink()
def _tool_status(self, name: Any, tool_input: Any) -> str:
label = status_label(str(name), tool_input, self.texts.tool_labels)
@@ -46,6 +46,7 @@ class TurnRecord:
agent_name: str
input_messages: list[MessageParam]
output_message: Message
thinking_is_progress: bool
system: str | None = None
source: str = "anthropic"
+68 -5
View File
@@ -13,21 +13,26 @@ import difflib
import fnmatch
import logging
import os
import re
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING
from zoneinfo import ZoneInfo
from watchfiles import awatch
from watchfiles import DefaultFilter, awatch
if TYPE_CHECKING:
from collections.abc import Iterable
__all__ = ["Change", "VaultWatch", "WatchRules"]
from watchfiles import Change as FileChange
__all__ = ["Change", "VaultFilter", "VaultWatch", "WatchRules"]
_log = logging.getLogger("beaver_gateway.vault.watch")
_FENCE = re.compile(r"^\s*(`{3,}|~{3,})\s*(.*?)\s*$")
@dataclass(frozen=True, slots=True)
class WatchRules:
@@ -42,6 +47,8 @@ class WatchRules:
names: tuple[str, ...] = ()
ignore: tuple[str, ...] = ()
suffixes: tuple[str, ...] = (".md",)
skip_fences: tuple[str, ...] = ("tasks", "dataview", "dataviewjs")
"""Fenced blocks of these languages are queries: ``full`` files drop them."""
def kind(self, rel: str, *, today: str) -> str | None:
"""The most specific matching pattern wins, whichever list it is in."""
@@ -74,6 +81,26 @@ class _Seen:
count: int
IGNORE_DIRS = (".obsidian", ".trash", ".git")
"""Housekeeping directories whose churn never means an edit of the vault."""
IGNORE_NAMES = (".sync.lock", "*.lock", "*.tmp", "*~")
"""Lock and scratch file names, matched against the entity name."""
class VaultFilter(DefaultFilter):
"""Drops housekeeping paths inside ``awatch``, before the loop is woken."""
def __init__(self) -> None:
super().__init__(ignore_dirs=(*DefaultFilter.ignore_dirs, *IGNORE_DIRS))
def __call__(self, change: FileChange, path: str) -> bool:
name = Path(path).name
if any(fnmatch.fnmatchcase(name, pattern) for pattern in IGNORE_NAMES):
return False
return super().__call__(change, path)
class VaultWatch:
def __init__(
self, root: Path, rules: WatchRules, *, tz: str = "UTC", debounce: float = 4.0
@@ -98,6 +125,7 @@ class VaultWatch:
_log.info("watching %s (%d files in snapshot)", self.root, len(self._seen))
async for changes in awatch(
self.root,
watch_filter=VaultFilter(),
debounce=int(self._debounce * 1000),
stop_event=self._stop,
ignore_permission_denied=True,
@@ -117,7 +145,7 @@ class VaultWatch:
if kind is None:
continue
with contextlib.suppress(OSError):
lines = _read_lines(path)
lines = self._lines(path, full=kind == "full")
self._seen[rel] = _Seen(
lines=lines if kind == "full" else None, count=len(lines)
)
@@ -135,7 +163,7 @@ class VaultWatch:
self._pending.pop(rel, None)
return None
stat = path.stat()
lines = _read_lines(path)
lines = self._lines(path, full=kind == "full")
seen = self._seen.get(rel)
change = Change(
path=rel,
@@ -163,12 +191,16 @@ class VaultWatch:
for change in changes:
path = self.root / change.path
with contextlib.suppress(OSError):
lines = _read_lines(path)
lines = self._lines(path, full=change.full)
self._seen[change.path] = _Seen(
lines=lines if change.full else None, count=len(lines)
)
return changes
def _lines(self, path: Path, *, full: bool) -> list[str]:
lines = _read_lines(path)
return _prose(lines, self.rules.skip_fences) if full else lines
def _rel(self, path: Path) -> str:
return path.resolve().relative_to(self.root.resolve()).as_posix()
@@ -193,6 +225,37 @@ def _read_lines(path: Path) -> list[str]:
return path.read_text(encoding="utf-8", errors="replace").splitlines()
def _prose(lines: list[str], skip_fences: tuple[str, ...]) -> list[str]:
start = 0
if lines and lines[0].strip() == "---":
closing = next(
(i for i, line in enumerate(lines[1:], 1) if line.strip() == "---"), None
)
start = closing + 1 if closing is not None else 0
out: list[str] = []
fence: str | None = None
skipping = False
for line in lines[start:]:
match = _FENCE.match(line)
if fence is None and match:
fence = match.group(1)
language = match.group(2).split(maxsplit=1)[0] if match.group(2) else ""
skipping = language.lower() in skip_fences
elif fence is not None and match and _closes(match, fence):
fence = None
if skipping:
skipping = False
continue
if not skipping and line.strip():
out.append(line)
return out
def _closes(match: re.Match[str], fence: str) -> bool:
mark = match.group(1)
return mark[0] == fence[0] and len(mark) >= len(fence) and not match.group(2)
def _added(old: list[str], new: list[str]) -> list[str]:
matcher = difflib.SequenceMatcher(a=old, b=new, autojunk=False)
added: list[str] = []
+48
View File
@@ -396,6 +396,54 @@ async def test_describe_snapshots_open_tools_and_records_rate_limit(
assert page["offset"] == 0 and len(page["entries"]) == 1
async def test_sessions_show_their_processes_and_take_orders(world: World) -> None:
world.backend._factory = ToolClient # noqa: SLF001
api = Api(world)
api.store.grant("api-only", "weak", scope="api")
weak = {"Authorization": "Bearer weak"}
conv = await world.conversations.create(kind="master", agent="a", origin="test")
key = conv.external_id
ScriptedClient.hold = asyncio.Event()
await world.conversations.post(conv, "go")
await asyncio.sleep(0.3)
census = await api.get("/sessions")
(row,) = census["sessions"]
assert row["key"] == key and row["busy"]
assert row["processes"] == [] and row["cpu"] is None
assert isinstance(census["others"], list)
live = world.pool.get(key)
assert live is not None
live.stderr.append("warning: something")
stderr = await api.get(f"/sessions/{key}/stderr")
assert stderr["lines"] == ["warning: something"]
for path in (f"/sessions/{key}/close", "/processes/1/kill"):
assert (await api.http.post(path, headers=weak)).status_code == 403
assert (
await api.http.get(f"/sessions/{key}/stderr", headers=weak)
).status_code == 403
closed = await api.http.post(f"/sessions/{key}/close", headers=HEADERS)
assert closed.json() == {"key": key, "closed": True}
assert world.pool.get(key) is None
deadline = asyncio.get_running_loop().time() + 5
while ("user", "failed") not in await world.statuses(conv):
assert asyncio.get_running_loop().time() < deadline, "the cut turn hangs"
await asyncio.sleep(0.02)
again = await api.http.post(f"/sessions/{key}/close", headers=HEADERS)
assert again.status_code == 404
gone = await api.http.post("/processes/999999999/kill", headers=HEADERS)
assert gone.status_code == 404
ScriptedClient.hold = asyncio.Event()
await world.conversations.post(conv, "once more")
await asyncio.sleep(0.3)
interrupted = await api.http.post(f"/sessions/{key}/interrupt", headers=weak)
assert interrupted.json() == {"key": key, "interrupted": True}
assert ScriptedClient.instances[-1].interrupted
kinds = [r["kind"] for r in (await api.get("/audit"))["records"]]
assert kinds == ["session_interrupt", "session_close"]
async def test_tokens_and_audit_need_admin_scope(world: World) -> None:
api = Api(world)
api.store.grant("api-only", "weak", scope="api")
+115 -1
View File
@@ -20,7 +20,11 @@ from claude_agent_sdk import (
project_key_for_directory,
)
from anthropic.types import TextBlock as AnthropicTextBlock
from anthropic.types import ThinkingBlock
from beaver_gateway.agents.claude import ClaudeAgent, ClaudeOptions
from beaver_gateway.agents.raycast import RaycastAgent
from beaver_gateway.backends.claude_sdk import ClaudeSdkBackend
from beaver_gateway.events.bus import EventBus
from beaver_gateway.conversations.service import (
@@ -36,6 +40,7 @@ from beaver_gateway.backends.transcript import (
close_open_tool_uses,
open_tool_uses,
render_messages,
reply_text,
strip_tool_entries,
window_entries,
)
@@ -714,12 +719,86 @@ def test_transcript_helpers() -> None:
assert windowed[0]["message"]["content"] == "q2" and len(windowed) == 2
assert (
render_messages(
[{"role": "user", "content": "q"}, {"role": "assistant", "content": "a"}]
[{"role": "user", "content": "q"}, {"role": "assistant", "content": "a"}],
thinking_is_progress=True,
)
== "user:\nq\n\nassistant:\na"
)
def test_read_conversation_keeps_progress_and_drops_reasoning() -> None:
history = [
{"role": "user", "content": "status?"},
{
"role": "assistant",
"content": [
{"type": "text", "text": "Checking."},
{"type": "thinking", "thinking": "", "signature": "s"},
{"type": "tool_use", "id": "t1", "name": "komodo", "input": {}},
],
},
{
"role": "user",
"content": [{"type": "tool_result", "tool_use_id": "t1", "content": "ok"}],
},
{
"role": "assistant",
"content": [
{
"type": "thinking",
"thinking": "All up, reading logs.",
"signature": "s",
},
{"type": "tool_use", "id": "t2", "name": "komodo", "input": {}},
],
},
{
"role": "user",
"content": [{"type": "tool_result", "tool_use_id": "t2", "content": "ok"}],
},
{"role": "assistant", "content": [{"type": "text", "text": "Healthy."}]},
]
assert render_messages(history, thinking_is_progress=True) == (
"user:\nstatus?\n\nassistant:\nChecking.\n\nAll up, reading logs.\n\n"
"Healthy.\n(tools: komodo, komodo)"
)
assert render_messages(history, thinking_is_progress=False) == (
"user:\nstatus?\n\nassistant:\nChecking.\n\nHealthy.\n(tools: komodo, komodo)"
)
@pytest.mark.parametrize(
("model", "expected"),
[
("claude-opus-5-5", True),
("claude-fable-5-1", True),
("claude-opus-5", False),
("claude-sonnet-5", False),
("claude-haiku-4-5-20251001", False),
("claude-next-unknown", False),
],
)
def test_only_progress_models_read_their_thinking_as_progress(
tmp_path: Path, model: str, expected: bool
) -> None:
assert (
ClaudeAgent(name="c", model=model, cwd=tmp_path).thinking_is_progress
is expected
)
def test_summarized_display_and_raycast_are_never_progress(tmp_path: Path) -> None:
raycast = RaycastAgent(name="r", model="google-gemini-3.1-pro", system_prompt="")
summarized = ClaudeAgent(
name="s",
model="claude-opus-5-5",
cwd=tmp_path,
options=ClaudeOptions(thinking={"type": "adaptive", "display": "summarized"}),
)
assert not raycast.thinking_is_progress
assert not summarized.thinking_is_progress
async def test_agent_kinds_gate_create(world: World) -> None:
with pytest.raises(ValueError, match="does not serve kind 'deep'"):
await world.conversations.create(kind="deep", agent="a", origin="test")
@@ -958,3 +1037,38 @@ async def test_subagent_result_inside_a_turn_does_not_end_it(world: World) -> No
assert client.prompts[-1].startswith("two")
rows = await world.conversations.queue.recent(conv.id)
assert [r.status for r in rows] == ["done", "done"]
def test_reply_text_keeps_progress_updates_and_drops_hidden_reasoning() -> None:
blocks = [
ThinkingBlock(type="thinking", thinking="", signature="s"),
ThinkingBlock(type="thinking", thinking="looking it up", signature="s"),
AnthropicTextBlock(type="text", text="found it"),
]
assert reply_text(blocks, thinking_is_progress=True) == "looking it up\n\nfound it"
assert reply_text(blocks, thinking_is_progress=False) == "found it"
def test_reply_text_on_a_plain_answer_is_unchanged() -> None:
blocks = [AnthropicTextBlock(type="text", text=" just an answer ")]
assert reply_text(blocks, thinking_is_progress=True) == "just an answer"
async def test_a_conversation_stream_carries_updates_about_that_conversation() -> None:
bus = EventBus()
seen: list[tuple[str, str | None]] = []
async def collect() -> None:
async for event in bus.stream(conversation_id="c1"):
seen.append((event["type"], event.get("id")))
if len(seen) == 2:
return
task = asyncio.create_task(collect())
await asyncio.sleep(0)
bus.publish("conversation.updated", id="c2", running_turn=None)
bus.publish("conversation.updated", id="c1", running_turn=None)
bus.publish("stream", conversation_id="c2")
bus.publish("stream", conversation_id="c1")
await asyncio.wait_for(task, 1)
assert seen == [("conversation.updated", "c1"), ("stream", None)]
+54 -1
View File
@@ -7,7 +7,11 @@ from test_conversations import ScriptedClient, World, world
from beaver_gateway.conversations.service import UserSaid
from beaver_gateway.conversations.envelope import Envelope, RecallContext, render
from beaver_gateway.conversations.texts import EnvelopeTexts
from beaver_gateway.conversations.texts import (
ConversationTexts,
EnvelopeTexts,
split_service,
)
from beaver_gateway.vault.watch import Change, VaultWatch, WatchRules
__all__ = ["world"]
@@ -77,6 +81,37 @@ def test_watch_reports_only_added_lines_with_mtime() -> None:
assert only.added == ["- 14:00 снова"]
def test_a_templated_note_shows_its_text_without_queries_or_frontmatter() -> None:
root, watch = vault()
rel = f"дни/{watch.today()}.md"
diary = root / rel
watch._seen.pop(rel)
diary.write_text(
"---\ntags:\n - дни\n---\n## сводка\n- \n\n\n---\n\n# планы\nпросрочены:\n"
"```tasks\nnot done\ndue before 2026-10-13\n```\n"
"обязательно сегодня:\n~~~dataview\nlist\n~~~\n"
"```python\nprint(1)\n```\n06:04 - спать\n"
)
watch.note(diary)
(change,) = watch.take()
assert change.added == [
"## сводка",
"- ",
"---",
"# планы",
"просрочены:",
"обязательно сегодня:",
"```python",
"print(1)",
"```",
"06:04 - спать",
]
append(diary, "```tasks\ndone on 2026-10-13\n```\n07:10 - встал\n")
watch.note(diary)
(later,) = watch.take()
assert later.added == ["07:10 - встал"]
def test_envelope_respects_ceilings_and_names_only_window() -> None:
root, watch = vault()
today = watch.today()
@@ -129,6 +164,24 @@ def test_render_hits_total_ceiling() -> None:
assert lines[1] == "time: 2026-08-26 13:04 (Warsaw)"
def test_the_users_words_part_from_what_the_gateway_put_under_them() -> None:
texts = ConversationTexts()
envelope = f"{HEADER}\ntime: 2026-10-13 10:00 (UTC)"
bundle = texts.bundle_header.format(since="2026-10-13T10:00:00+00:00")
injects = f"{bundle}\n- [cron] tick"
assert split_service(f"hello\n\n{envelope}", texts) == ("hello", envelope)
assert split_service(f"hello\n\n{envelope}\n\n{injects}", texts) == (
"hello",
f"{envelope}\n\n{injects}",
)
assert split_service(f"hello\n\n{injects}", texts) == ("hello", injects)
assert split_service(f"\n\n{envelope}", texts) == ("", envelope)
assert split_service("hello\n\n[a note of mine]\nmore", texts) == (
"hello\n\n[a note of mine]\nmore",
None,
)
async def test_master_turn_gets_envelope_after_text_and_before_injects(
world: World,
) -> None:
+152 -24
View File
@@ -1,6 +1,11 @@
from pathlib import Path
from anthropic.types import Message, TextBlock, ThinkingBlock, ToolUseBlock, Usage
from beaver_gateway.frontends.markdown import renderer
from beaver_gateway.frontends.markdown import history, parser, renderer
from beaver_gateway.frontends.markdown.crossfront import CrossFrontendLogger
from beaver_gateway.frontends.markdown.parser import TextSegment, ToolSegment
from beaver_gateway.frontends.turn_record import TurnRecord
def _message(*blocks: object) -> Message:
@@ -24,36 +29,159 @@ def test_tool_calls_leave_no_blank_lines() -> None:
ToolUseBlock(type="tool_use", id="t3", name="Grep", input={"pattern": "y"}),
TextBlock(type="text", text="second"),
)
assert renderer.render_assistant_message(message) == (
"### Assistant:\n\nfirst\n\nsecond\n"
for progress in (True, False):
assert renderer.render_assistant_message(
message, thinking_is_progress=progress
) == ("### Assistant:\n\nfirst\n\nsecond\n")
def _progress_turn() -> Message:
return _message(
TextBlock(type="text", text="Checking the containers."),
ThinkingBlock(type="thinking", thinking="", signature="s"),
ToolUseBlock(type="tool_use", id="t1", name="komodo", input={}),
ThinkingBlock(type="thinking", thinking="", signature="s"),
ThinkingBlock(type="thinking", thinking="All up, reading logs.", signature="s"),
ToolUseBlock(type="tool_use", id="t2", name="komodo", input={}),
ThinkingBlock(type="thinking", thinking="Found it.\nOne more.", signature="s"),
ToolUseBlock(type="tool_use", id="t3", name="komodo", input={}),
TextBlock(type="text", text="The stack is healthy."),
)
def test_thinking_renders_as_collapsed_callout() -> None:
message = _message(
ThinkingBlock(type="thinking", thinking="a\nb", signature="s"),
def _reasoning_turn() -> Message:
return _message(
ThinkingBlock(type="thinking", thinking="one", signature=""),
ToolUseBlock(type="tool_use", id="t1", name="web_search", input={}),
ThinkingBlock(type="thinking", thinking="two", signature=""),
ToolUseBlock(type="tool_use", id="t2", name="read_page", input={}),
ThinkingBlock(type="thinking", thinking="three", signature=""),
TextBlock(type="text", text="answer"),
)
assert renderer.render_assistant_message(message) == (
"### Assistant:\n\n> [!thinking]-\n> a\n> b\n\nanswer\n"
)
def test_thinking_between_hidden_tool_calls_collapses_to_the_first() -> None:
message = _message(
ThinkingBlock(type="thinking", thinking="one", signature="s"),
ToolUseBlock(type="tool_use", id="t1", name="Read", input={}),
ThinkingBlock(type="thinking", thinking="two", signature="s"),
ToolUseBlock(type="tool_use", id="t2", name="Read", input={}),
ThinkingBlock(type="thinking", thinking="three", signature="s"),
TextBlock(type="text", text="answer"),
ThinkingBlock(type="thinking", thinking="four", signature="s"),
ThinkingBlock(type="thinking", thinking="four\nfive", signature=""),
TextBlock(type="text", text="more"),
)
assert renderer.render_assistant_message(message) == (
"### Assistant:\n\n> [!thinking]-\n> one\n\nanswer\n\n"
"> [!thinking]-\n> four\n\nmore\n"
REASONING_TURN_AS_BEFORE = (
"### Assistant:\n\n> [!thinking]-\n> one\n\nanswer\n\n"
"> [!thinking]-\n> four\n> five\n\nmore\n"
)
def _full_circle(
message: Message, *, thinking_is_progress: bool
) -> history.ForkOutcome:
body = renderer.render_user_text("status?") + renderer.render_assistant_message(
message, thinking_is_progress=thinking_is_progress
)
turns = parser.parse(body + renderer.render_user_text("thanks")).turns
stored = [
{"role": "user", "content": "status?"},
{"role": "assistant", "content": [b.model_dump() for b in message.content]},
]
outcome = history.diff_and_fork(
stored=stored, incoming=turns, thinking_is_progress=thinking_is_progress
)
assert outcome.divergence_index is None
assert not outcome.edited
assert outcome.messages[1] == stored[1]
return outcome
def test_progress_renders_as_text_in_place_and_empty_thinking_vanishes() -> None:
assert renderer.render_assistant_message(
_progress_turn(), thinking_is_progress=True
) == (
"### Assistant:\n\nChecking the containers.\n\nAll up, reading logs.\n\n"
"Found it.\nOne more.\n\nThe stack is healthy.\n"
)
def test_reasoning_renders_as_collapsed_callouts_as_before() -> None:
assert (
renderer.render_assistant_message(_reasoning_turn(), thinking_is_progress=False)
== REASONING_TURN_AS_BEFORE
)
def test_progress_round_trips_through_the_parser_and_the_stored_history() -> None:
message = _progress_turn()
body = renderer.render_assistant_message(message, thinking_is_progress=True)
assert parser.parse_assistant_structure(body.removeprefix("### Assistant:")) == [
TextSegment(
text="Checking the containers.\n\nAll up, reading logs.\n\n"
"Found it.\nOne more.\n\nThe stack is healthy."
)
]
_full_circle(message, thinking_is_progress=True)
def test_reasoning_round_trips_through_the_parser_and_the_stored_history() -> None:
message = _reasoning_turn()
body = renderer.render_assistant_message(message, thinking_is_progress=False)
assert parser.parse_assistant_structure(body.removeprefix("### Assistant:")) == [
TextSegment(text="answer"),
TextSegment(text="more"),
]
_full_circle(message, thinking_is_progress=False)
def test_an_edited_progress_turn_rebuilds_instead_of_rewriting_signed_blocks() -> None:
message = _progress_turn()
body = renderer.render_assistant_message(
message, thinking_is_progress=True
).replace("Found it", "Fixed it")
turns = parser.parse(
renderer.render_user_text("status?") + body + renderer.render_user_text("ok")
).turns
stored = [
{"role": "user", "content": "status?"},
{"role": "assistant", "content": [b.model_dump() for b in message.content]},
]
outcome = history.diff_and_fork(
stored=stored, incoming=turns, thinking_is_progress=True
)
assert outcome.edited
assert outcome.messages[1] == {
"role": "assistant",
"content": [{"type": "text", "text": turns[1].text}],
}
async def test_a_raycast_archive_with_reasoning_renders_as_before(
tmp_path: Path,
) -> None:
logger = CrossFrontendLogger(vault_path=tmp_path, logged_subdir="logged")
record = TurnRecord(
agent_name="beaver-gemini-pro-high",
input_messages=[{"role": "user", "content": "q"}],
output_message=_reasoning_turn(),
thinking_is_progress=False,
)
await logger.handle(record)
[written] = (tmp_path / "logged" / "beaver-gemini-pro-high").glob("*.md")
body = parser.parse(written.read_text()).body
assert f"---\n\n{REASONING_TURN_AS_BEFORE}\n---\n\n### User:" in body
def test_a_file_with_collapsed_thinking_callouts_parses_as_before() -> None:
body = (
"### User:\n\nq\n\n---\n\n### Assistant:\n\n"
"> [!thinking]-\n> weighing it\n> twice\n\nanswer\n\n"
"> [!tool]- Read · notes.md\n> input\n\n"
"> [!thinking]-\n>\n\nmore\n\n---\n\n### User:\n\nnext\n"
)
parsed = parser.parse(body)
assert parsed.turns[1].structure == (
TextSegment(text="answer"),
ToolSegment(name="Read"),
TextSegment(text="more"),
)
assert parsed.messages == [
{"role": "user", "content": "q"},
{"role": "assistant", "content": "answer\n\nmore"},
{"role": "user", "content": "next"},
]
def test_user_scaffold_matches_rendered_user_turn_shape() -> None:
+92
View File
@@ -0,0 +1,92 @@
import os
import subprocess
import sys
import time
from beaver_gateway.backends.processes import Processes, _command, subtree
SLEEP = "import time; time.sleep(60)"
NEST = (
"import subprocess, sys, time; "
f"subprocess.Popen([sys.executable, '-c', {SLEEP!r}]); time.sleep(60)"
)
def spawn(code: str) -> subprocess.Popen[bytes]:
return subprocess.Popen([sys.executable, "-c", code])
def rows_under(processes: Processes, pid: int, count: int) -> list[dict]:
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
rows = subtree(processes.tree_parents_first(), pid)
if len(rows) >= count:
return rows
time.sleep(0.05)
msg = f"{pid} never grew to {count} processes"
raise AssertionError(msg)
def test_the_tree_shows_what_the_gateway_started_and_what_it_costs() -> None:
child = spawn(SLEEP)
try:
processes = Processes()
(row,) = rows_under(processes, child.pid, 1)
assert row["parent"] == os.getpid()
assert row["depth"] == 0
assert row["rss"] > 0
assert row["cpu"] is None
assert "time.sleep" in row["command"]
(again,) = rows_under(processes, child.pid, 1)
assert again["cpu"] is not None
finally:
child.kill()
child.wait()
def test_kill_takes_the_branch_and_nothing_the_gateway_did_not_start() -> None:
parent = spawn(NEST)
try:
processes = Processes()
top, nested = rows_under(processes, parent.pid, 2)
assert (top["depth"], nested["depth"]) == (0, 1)
assert not processes.kill_own_descendant(os.getpid())
assert not processes.kill_own_descendant(os.getppid())
assert processes.kill_own_descendant(parent.pid)
parent.wait(timeout=5)
alive = {row["pid"] for row in processes.tree_parents_first()}
assert not alive & {top["pid"], nested["pid"]}
finally:
parent.kill()
parent.wait()
def test_a_subtree_ends_at_the_next_sibling() -> None:
rows = [
{"pid": 1, "depth": 0},
{"pid": 2, "depth": 1},
{"pid": 3, "depth": 2},
{"pid": 4, "depth": 1},
{"pid": 5, "depth": 0},
]
assert [(r["pid"], r["depth"]) for r in subtree(rows, 2)] == [(2, 0), (3, 1)]
assert [r["pid"] for r in subtree(rows, 1)] == [1, 2, 3, 4]
assert subtree(rows, 9) == []
assert subtree(rows, None) == []
def test_a_command_line_keeps_prompts_and_configs_to_itself() -> None:
argv = [
"/opt/claude/claude",
"--model",
"m",
"--system-prompt",
"you are a beaver",
"--mcp-config",
'{"gateway": {"headers": {"Authorization": "Bearer abc"}}}',
"x" * 100,
]
assert _command(argv) == (
"claude --model m --system-prompt … --mcp-config … " + "x" * 80 + "…"
)
assert _command([]) == ""
+104
View File
@@ -20,6 +20,7 @@ from beaver_gateway.frontends.base import GatewayRuntime
from beaver_gateway.frontends.files import SendFileError, SendFiles, method_for
from beaver_gateway.frontends.telegram import Attachments, TelegramFrontend
from beaver_gateway.frontends.telegram.drafts import Draft
from beaver_gateway.frontends.telegram.texts import TelegramTexts
from beaver_gateway.frontends.telegram.render import (
LIMIT,
chunks,
@@ -1194,3 +1195,106 @@ async def test_a_refused_upload_comes_back_to_the_agent(stack: Stack) -> None:
outside = await _send(stack, master, path="/etc/passwd")
assert outside["is_error"]
assert "outside the allowed roots" in outside["content"][0]["text"]
def _progress_draft() -> Draft:
return Draft(cast("Any", None), chat_id=USER, thread_id=None, turn_id="t")
def _thinking_delta(text: str) -> Any:
return {
"event": {
"type": "content_block_delta",
"index": 0,
"delta": {"type": "thinking_delta", "thinking": text},
}
}
def test_progress_updates_reach_the_draft_but_hidden_reasoning_does_not() -> None:
draft = _progress_draft()
texts = TelegramTexts()
stub = SimpleNamespace(_drafts={"k": draft}, texts=texts)
feed = functools.partial(
TelegramFrontend._feed_draft, cast("Any", stub), "k", thinking_is_progress=True
)
feed(_thinking_delta(""))
assert draft.text == ""
assert draft.status == texts.thinking
feed(_thinking_delta("checking the vault"))
assert draft.text == "checking the vault"
assert draft.status == texts.writing
def test_reasoning_rides_under_the_status_and_never_reaches_the_text() -> None:
draft = _progress_draft()
texts = TelegramTexts()
stub = SimpleNamespace(_drafts={"k": draft}, texts=texts)
feed = functools.partial(
TelegramFrontend._feed_draft, cast("Any", stub), "k", thinking_is_progress=False
)
feed(_thinking_delta("weighing <it> "))
feed(_thinking_delta("against the note"))
assert draft.text == ""
assert draft.status == texts.thinking
assert draft._render() == (
f"{texts.thinking}\n\n"
"<blockquote>weighing &lt;it&gt; against the note</blockquote>",
f"{texts.thinking}\n\nweighing <it> against the note",
)
feed(
{
"event": {
"type": "content_block_start",
"index": 2,
"content_block": {"type": "thinking"},
}
}
)
assert draft.reasoning == ""
feed(_thinking_delta("word " * 200))
shown = draft._render()[1].removeprefix(f"{texts.thinking}\n\n")
assert shown.startswith("… word")
assert len(shown) <= 402
feed(
{
"event": {
"type": "content_block_delta",
"index": 3,
"delta": {"type": "text_delta", "text": "Done."},
}
}
)
assert draft.reasoning == ""
assert draft._render()[1] == f"{texts.writing}\n\nDone."
def test_a_tool_call_between_progress_updates_starts_a_new_paragraph() -> None:
draft = _progress_draft()
texts = TelegramTexts()
stub = SimpleNamespace(
_drafts={"k": draft},
texts=texts,
_tool_status=lambda name, _input: texts.tool.format(label=name),
)
feed = functools.partial(
TelegramFrontend._feed_draft, cast("Any", stub), "k", thinking_is_progress=True
)
feed(_thinking_delta("reading the note"))
feed(
{
"event": {
"type": "content_block_start",
"index": 1,
"content_block": {"type": "tool_use", "name": "Read"},
}
}
)
feed(_thinking_delta("now writing it back"))
assert draft.text == "reading the note\n\nnow writing it back"
+63
View File
@@ -0,0 +1,63 @@
import tempfile
from collections.abc import AsyncIterator
from pathlib import Path
import pytest
from watchfiles import Change as FileChange
from beaver_gateway.vault import watch as watch_module
from beaver_gateway.vault.watch import VaultFilter, VaultWatch, WatchRules
VAULT = Path("/vault")
FILTER = VaultFilter()
def wakes(rel: str) -> bool:
return FILTER(FileChange.modified, str(VAULT / rel))
def test_obsidian_never_wakes_the_watcher() -> None:
assert not wakes(".obsidian/.sync.lock")
assert not wakes(".obsidian/.sync.lock/data")
assert not wakes(".obsidian")
assert not wakes(".obsidian/workspace.json")
assert not wakes(".obsidian/plugins/obsidian-git/data.json")
def test_other_service_paths_never_wake_the_watcher() -> None:
assert not wakes(".trash/📅 дни/2026-09-01.md")
assert not wakes(".git/index")
def test_notes_still_wake_the_watcher() -> None:
assert wakes("📅 дни/2026-09-09.md")
assert wakes("мета/бобер/состояние.md")
assert wakes("заметка.md")
assert wakes("💻 проекты/бобер/план.md")
def test_lock_beside_a_note_is_quiet_but_the_note_is_not() -> None:
assert not wakes("📅 дни/.2026-09-09.md.lock")
assert not wakes("📅 дни/2026-09-09.md.tmp")
assert not wakes("📅 дни/2026-09-09.md~")
assert wakes("📅 дни/2026-09-09.md")
async def test_the_filter_is_applied_by_awatch_itself(
monkeypatch: pytest.MonkeyPatch,
) -> None:
seen: dict[str, object] = {}
async def fake_awatch(
*paths: object, **kwargs: object
) -> AsyncIterator[set[object]]:
seen.update(kwargs)
seen["paths"] = paths
for change in ():
yield change
monkeypatch.setattr(watch_module, "awatch", fake_awatch)
root = Path(tempfile.mkdtemp(prefix="beaver-vault-"))
watch = VaultWatch(root, WatchRules(names=("**",)), tz="UTC")
await watch.run()
assert isinstance(seen["watch_filter"], VaultFilter)
+16
View File
@@ -313,6 +313,22 @@ export class ApiClient {
return this.get("/api/sessions");
}
sessionStderr(key: string): Promise<{ key: string; lines: string[] }> {
return this.get(`/api/sessions/${encodeURIComponent(key)}/stderr`);
}
interruptSession(key: string): Promise<{ interrupted: boolean }> {
return this.post(`/api/sessions/${encodeURIComponent(key)}/interrupt`);
}
closeSession(key: string): Promise<{ closed: boolean }> {
return this.post(`/api/sessions/${encodeURIComponent(key)}/close`);
}
killProcess(pid: number): Promise<{ killed: boolean }> {
return this.post(`/api/processes/${pid}/kill`);
}
jobs(): Promise<JobsResponse> {
return this.get("/api/jobs");
}
+19
View File
@@ -79,6 +79,7 @@ export interface ConversationInfo extends ConversationSummary {
parent: string | null;
question: PendingQuestion | null;
queue: QueueItem[];
thinking_is_progress?: boolean;
turn: TurnSnapshot | null;
}
@@ -105,7 +106,9 @@ export interface ContentBlock {
export interface HistoryMessage {
content: string | ContentBlock[];
id?: string | null;
role: "user" | "assistant";
service?: string;
// When the first block of this message was written; absent for old rows.
ts?: string | null;
}
@@ -143,17 +146,31 @@ export interface AgentsResponse {
mcps: { name: string; kind: string }[];
}
export interface ProcessRow {
age_seconds: number;
command: string;
cpu: number | null;
depth: number;
name: string;
parent: number;
pid: number;
rss: number;
}
export interface SessionRow {
age_seconds: number;
agent: string;
busy: boolean;
cpu: number | null;
dirty: boolean;
idle_seconds: number;
key: string;
kind: string;
pending: number;
pending_question: boolean;
pid: number | null;
pinned: boolean;
processes: ProcessRow[];
rss: number | null;
running_turn: string | null;
session_id: string | null;
@@ -161,6 +178,8 @@ export interface SessionRow {
}
export interface SessionsResponse {
cpu: number | null;
others: ProcessRow[];
rss: number | null;
rss_limit: number | null;
sessions: SessionRow[];
+6
View File
@@ -68,6 +68,12 @@ export function fmtSeconds(seconds: number | null | undefined): string {
: fmtDuration(seconds * THOUSAND);
}
export function fmtCpu(percent: number | null | undefined): string {
return percent === null || percent === undefined
? "–"
: `${Math.round(percent)}%`;
}
export function fmtBytes(bytes: number | null | undefined): string {
if (bytes === null || bytes === undefined) {
return "–";
+39 -11
View File
@@ -30,6 +30,8 @@ export interface Turn {
// A tool call came between two pieces of text; the next piece starts a
// paragraph, as the final message will show it.
paused: boolean;
// What the model thought, when its thinking is reasoning and not progress.
reasoning: string;
resultSubtype: string | null;
roots: string[];
says: string[];
@@ -223,20 +225,45 @@ export class ActivityModel {
const turn = this.turnFor(turnId, ts, parent);
const sdk = raw as Record<string, unknown>;
if (sdk.type === "content_block_start") {
const block = sdk.content_block as Record<string, unknown> | undefined;
if (block?.type === "tool_use" && typeof block.id === "string") {
this.ensureNode(turn, block.id, str(block.name) ?? "?", parent, ts);
} else if (block?.type === "thinking" && parent === null) {
turn.thinking += 1;
this.onBlockStart(turn, sdk.content_block, parent, ts);
} else if (sdk.type === "content_block_delta" && parent === null) {
this.onDelta(turn, sdk.delta as Record<string, unknown> | undefined);
}
}
private onBlockStart(
turn: Turn,
raw: unknown,
parent: string | null,
ts: string
): void {
const block = raw as Record<string, unknown> | undefined;
if (block?.type === "tool_use" && typeof block.id === "string") {
this.ensureNode(turn, block.id, str(block.name) ?? "?", parent, ts);
} else if (block?.type === "thinking" && parent === null) {
turn.thinking += 1;
if (turn.reasoning) {
turn.reasoning += "\n\n";
}
}
}
private onDelta(
turn: Turn,
delta: Record<string, unknown> | undefined
): void {
const thought = delta?.type === "thinking_delta" ? delta.thinking : null;
if (
typeof thought === "string" &&
this.conversation?.thinking_is_progress !== true
) {
turn.reasoning += thought;
return;
}
if (sdk.type === "content_block_delta" && parent === null) {
const delta = sdk.delta as Record<string, unknown> | undefined;
if (delta?.type === "text_delta" && typeof delta.text === "string") {
turn.text = joinText(turn.text, delta.text, turn.paused);
turn.paused = false;
}
const text = thought ?? delta?.text;
if (typeof text === "string" && text) {
turn.text = joinText(turn.text, text, turn.paused);
turn.paused = false;
}
}
@@ -360,6 +387,7 @@ export class ActivityModel {
nodes: {},
origin: origin ?? "?",
paused: false,
reasoning: "",
resultSubtype: null,
roots: [],
says: [],
+256 -161
View File
@@ -6,12 +6,16 @@
import ErrorNote from "$lib/components/error-note.svelte";
import { clip, fmtDateTime, fmtTime } from "$lib/format";
import { cn } from "$lib/utils";
import type { ActivityModel } from "./activity.svelte";
import { isLive, summarizeInput, toolLabel } from "./activity.svelte";
import type { ActivityModel, ToolNode } from "./activity.svelte";
import { isLive } from "./activity.svelte";
import { cacheableHistory, historyKey } from "./history-cache";
import { usePanelHost } from "./host";
import Markdown from "./markdown.svelte";
import QuestionCard from "./question-card.svelte";
import Reasoning from "./reasoning.svelte";
import SayBubble from "./say-bubble.svelte";
import ServiceLine from "./service-line.svelte";
import ToolNodeView from "./tool-node.svelte";
import TurnCard from "./turn-card.svelte";
let {
@@ -26,25 +30,42 @@
refreshKey?: number;
} = $props();
const RESULT_CLIP = 600;
// Markdown for the last few screens renders at once; the rest waits
// until scrolled near, so a long thread paints as fast as a short one.
const EAGER_MARKDOWN = 40;
interface Step {
block: ContentBlock;
eager: boolean;
key: string;
stampedAt: string | null;
}
type Row =
| { day: string; key: string; kind: "day" }
| { key: string; kind: "system"; text: string; ts: string | null }
| {
eager: boolean;
key: string;
kind: "user";
service: string | null;
texts: string[];
ts: string | null;
}
| { key: string; kind: "agent"; steps: Step[] };
const RESULT_CLIP = 20_000;
const RENDERED_AT_ONCE = 40;
const host = usePanelHost();
const NEAR_BOTTOM_PX = 120;
const AT_BOTTOM_PX = 8;
const TICK_MS = 1000;
const NO_NODES: Record<string, ToolNode> = {};
let messages = $state<HistoryMessage[] | null>(null);
let failure = $state<string | null>(null);
let showResults = $state(false);
let loadedAt = $state(new Date(0).toISOString());
let scroller = $state<HTMLDivElement | null>(null);
let body = $state<HTMLDivElement | null>(null);
const SETTLE_MS = 2500;
const SETTLE_STEP_MS = 120;
let settleUntil = 0;
let now = $state(Date.now());
let pinned = true;
let readerRow: { node: Element; top: number } | null = null;
let ownScroll = false;
const tail = $derived(
model.turns
@@ -53,7 +74,11 @@
);
const tailSize = $derived(
tail.reduce(
(n, turn) => n + turn.text.length + Object.keys(turn.nodes).length,
(n, turn) =>
n +
turn.text.length +
turn.reasoning.length +
Object.keys(turn.nodes).length,
0
)
);
@@ -71,46 +96,61 @@
try {
({ messages } = await client.history(conversationId));
loadedAt = new Date().toISOString();
settle();
host.cache?.set(historyKey(conversationId), cacheableHistory(messages));
} catch (cause) {
failure = cause instanceof Error ? cause.message : String(cause);
}
}
// The scroller is column-reverse: its origin is the bottom, scrollTop is
// zero there and goes negative upward, and the browser keeps the view at
// the bottom while content grows - no matter how late markdown lands.
function nearBottom(): boolean {
return !scroller || Math.abs(scroller.scrollTop) < NEAR_BOTTOM_PX;
}
function toBottom() {
if (scroller) {
scroller.scrollTop = 0;
}
}
async function scrollToBottom() {
await tick();
toBottom();
}
// A fresh thread keeps growing after it is on screen: markdown renders
// late, embeds load, fonts land. Hold the bottom for a while after a load.
function settle() {
settleUntil = Date.now() + SETTLE_MS;
const step = () => {
if (!pinned || Date.now() > settleUntil) {
return;
function topRow(): { node: Element; top: number } | null {
if (!(scroller && body)) {
return null;
}
const edge = scroller.getBoundingClientRect().top;
const rows = body.children;
let low = 0;
let high = rows.length - 1;
while (low < high) {
const middle = Math.floor((low + high) / 2);
if (rows[middle].getBoundingClientRect().bottom > edge) {
high = middle;
} else {
low = middle + 1;
}
}
const node = rows[low];
return node ? { node, top: node.getBoundingClientRect().top } : null;
}
function trackReader() {
if (ownScroll) {
ownScroll = false;
return;
}
pinned = !scroller || Math.abs(scroller.scrollTop) < AT_BOTTOM_PX;
readerRow = pinned ? null : topRow();
}
function keepReaderRowInPlace() {
if (pinned) {
toBottom();
setTimeout(step, SETTLE_STEP_MS);
};
step();
return;
}
if (!(scroller && readerRow?.node.isConnected)) {
return;
}
const drift = readerRow.node.getBoundingClientRect().top - readerRow.top;
const before = scroller.scrollTop;
scroller.scrollTop += drift;
ownScroll = scroller.scrollTop !== before;
}
// The date changes rarely; say it once, then only the time.
function dayOf(iso: string | null | undefined): string {
return iso ? iso.slice(0, 10) : "";
}
@@ -121,7 +161,6 @@
$effect(() => {
if (refreshKey >= 0) {
pinned = true;
untrack(() => load());
}
});
@@ -129,21 +168,15 @@
$effect(() => {
const count = (messages?.length ?? 0) + tailSize;
if (pinned && count >= 0) {
scrollToBottom();
tick().then(toBottom);
}
});
// Markdown lands asynchronously (Obsidian renders after the DOM exists),
// so follow the content's height, not just the message count.
$effect(() => {
if (!(scroller && body)) {
return;
}
const observer = new ResizeObserver(() => {
if (pinned && scroller) {
scroller.scrollTop = scroller.scrollHeight;
}
});
const observer = new ResizeObserver(keepReaderRowInPlace);
observer.observe(body);
return () => observer.disconnect();
});
@@ -157,42 +190,30 @@
return () => clearInterval(timer);
});
// Text the gateway or the CLI put in the user's seat: an inject header,
// an envelope, a subagent's report.
const SYSTEM_HEAD = /^(\[[^\]\n]+\]|<task-notification>)/;
const SECONDS = /:\d{2}$/;
const DAY_TIME = /,?\s*\d{2}:\d{2}$/;
let openSystem = $state<Set<number>>(new Set());
function systemText(message: HistoryMessage): string | null {
if (message.role !== "user" || typeof message.content !== "string") {
return null;
}
return SYSTEM_HEAD.test(message.content.trimStart())
? message.content
: null;
}
function toggleSystem(index: number) {
const next = new Set(openSystem);
if (next.has(index)) {
next.delete(index);
} else {
next.add(index);
}
openSystem = next;
}
// Keys count from the end: when the network copy grows the head past
// what the cache held, the tail keeps its nodes and its rendered markdown.
function keyOf(message: HistoryMessage, index: number, total: number) {
return `${index - total}:${message.role}:${message.ts ?? ""}`;
const fromEnd = `${index - total}:${message.role}:${message.ts ?? ""}`;
return message.id ?? fromEnd;
}
function blocks(message: HistoryMessage): ContentBlock[] {
return typeof message.content === "string"
? [{ text: message.content, type: "text" }]
: message.content;
if (typeof message.content === "string") {
return [{ text: message.content, type: "text" }];
}
if (!model.conversation?.thinking_is_progress) {
return message.content;
}
return message.content.flatMap((block) => {
if (block.type !== "thinking") {
return [block];
}
return block.thinking?.trim()
? [{ text: block.thinking, type: "text" }]
: [];
});
}
function resultText(block: ContentBlock): string {
@@ -211,28 +232,127 @@
}
return "";
}
const results = $derived.by(() => {
const found = new Map<string, { failed: boolean; text: string }>();
for (const message of messages ?? []) {
if (typeof message.content === "string") {
continue;
}
for (const block of message.content) {
if (block.type === "tool_result" && block.tool_use_id) {
found.set(block.tool_use_id, {
failed: block.is_error === true,
text: clip(resultText(block), RESULT_CLIP),
});
}
}
}
return found;
});
function nodeOf(block: ContentBlock): ToolNode {
const result = block.id ? results.get(block.id) : undefined;
let status: ToolNode["status"] = "aborted";
if (result) {
status = result.failed ? "error" : "done";
}
return {
children: [],
endedAt: null,
id: block.id ?? "",
input: block.input ?? null,
name: block.name ?? "?",
parent: null,
result: result?.text ?? "",
startedAt: "",
status,
};
}
function said(block: ContentBlock): string | null {
const text = (block.input as { text?: unknown } | null | undefined)?.text;
return block.name?.endsWith("__say") && typeof text === "string"
? text
: null;
}
function userRow(
message: HistoryMessage,
parts: ContentBlock[],
key: string,
eager: boolean
): Row {
const ts = message.ts ?? null;
const texts = parts.flatMap((b) =>
b.type === "text" && b.text ? [b.text] : []
);
if (!message.service && SYSTEM_HEAD.test(texts[0]?.trimStart() ?? "")) {
return { key, kind: "system", text: texts.join("\n"), ts };
}
const service = message.service ?? null;
return { eager, key, kind: "user", service, texts, ts };
}
function stepsOf(
message: HistoryMessage,
parts: ContentBlock[],
key: string,
eager: boolean,
minute: string
): Step[] {
const ts = message.ts ?? null;
const stamped =
minuteOf(ts) !== minute ||
parts.some((b) => b.type === "text" && Boolean(b.text));
return parts.map((block, at) => ({
block,
eager,
key: `${key}:${at}`,
stampedAt: stamped && at === 0 ? ts : null,
}));
}
const rows = $derived.by(() => {
const out: Row[] = [];
const list = messages ?? [];
let run: Step[] | null = null;
let day = "";
let minute = "";
for (const [index, message] of list.entries()) {
const parts = blocks(message);
if (parts.every((b) => b.type === "tool_result")) {
continue;
}
const key = keyOf(message, index, list.length);
const eager = index >= list.length - RENDERED_AT_ONCE;
if (message.ts && dayOf(message.ts) !== day) {
day = dayOf(message.ts);
const label = fmtDateTime(message.ts).replace(DAY_TIME, "");
out.push({ day: label, key, kind: "day" });
run = null;
}
if (message.role === "user") {
out.push(userRow(message, parts, key, eager));
run = null;
} else if (run === null) {
run = stepsOf(message, parts, key, eager, minute);
out.push({ key, kind: "agent", steps: run });
} else {
run.push(...stepsOf(message, parts, key, eager, minute));
}
minute = message.ts ? minuteOf(message.ts) : minute;
}
return out;
});
</script>
<div
class="flex min-h-0 flex-1 flex-col-reverse overflow-y-auto overflow-x-hidden px-3 py-3 [overflow-anchor:none] @md:px-6"
onscroll={() => {
if (Date.now() > settleUntil) {
pinned = nearBottom();
}
}}
onscroll={trackReader}
bind:this={scroller}
>
<div class="flex min-w-0 max-w-full flex-col gap-3" bind:this={body}>
<button
class="rule-word self-end text-xs"
data-active={showResults}
onclick={() => {
showResults = !showResults;
}}
type="button"
>
{showResults ? "hide tool results" : "show tool results"}
</button>
{#if failure}
<ErrorNote message={failure} retry={load} />
{:else if messages === null}
@@ -245,97 +365,72 @@
title="Empty conversation"
/>
{:else}
{#each messages as message, index (keyOf(message, index, messages.length))}
{@const parts = blocks(message)}
{@const isResultOnly = parts.every((b) => b.type === "tool_result")}
{@const system = systemText(message)}
{@const newDay =
message.ts && dayOf(message.ts) !== dayOf(messages[index - 1]?.ts)}
{@const newMinute =
message.ts &&
minuteOf(message.ts) !== minuteOf(messages[index - 1]?.ts)}
{@const spoken =
message.role === "user" ||
parts.some((b) => b.type === "text" && Boolean(b.text))}
{#if newDay}
<p class="label-quiet self-center pt-2">
{fmtDateTime(message.ts).replace(DAY_TIME, "")}
</p>
{/if}
{#if system}
{#each rows as row (`${row.kind}:${row.key}`)}
{#if row.kind === "day"}
<p class="label-quiet self-center pt-2">{row.day}</p>
{:else if row.kind === "system"}
<div class="flex min-w-0 items-start justify-center gap-2">
{#if message.ts}
{#if row.ts}
<span
class="tabular shrink-0 py-1 text-[11px] text-muted-foreground opacity-60"
title={fmtDateTime(message.ts)}
title={fmtDateTime(row.ts)}
>
{minuteOf(message.ts)}
{minuteOf(row.ts)}
</span>
{/if}
<button
aria-expanded={openSystem.has(index)}
class="@md:max-w-[75ch] min-w-0 max-w-full rounded-md px-2 py-1 text-left font-mono text-muted-foreground text-xs hover:bg-accent"
onclick={() => toggleSystem(index)}
type="button"
>
{#if openSystem.has(index)}
<span class="whitespace-pre-wrap break-words">{system}</span>
{:else}
<span class="block truncate"
>{clip(system.split("\n")[0], 120)}</span
>
{/if}
</button>
<ServiceLine text={row.text} />
</div>
{:else if !(isResultOnly && !showResults)}
<div
class={cn(
"group flex min-w-0 max-w-full flex-col gap-1.5",
message.role === "user" && !isResultOnly && "items-end"
)}
>
{#if message.ts && (newMinute || spoken) && !isResultOnly}
{:else if row.kind === "user"}
<div class="group flex min-w-0 max-w-full flex-col items-end gap-1.5">
{#if row.ts}
<span
class="tabular px-1 text-[11px] text-muted-foreground opacity-60 transition-opacity group-hover:opacity-100"
title={fmtDateTime(message.ts)}
title={fmtDateTime(row.ts)}
>
{minuteOf(message.ts)}
{minuteOf(row.ts)}
</span>
{/if}
{#each parts as block, blockIndex (blockIndex)}
{#each row.texts as text, index (index)}
<Markdown
class="min-w-0 @md:max-w-[75ch] max-w-full rounded-2xl rounded-br-md bg-primary/10 px-3.5 py-2"
eager={row.eager}
inert
{text}
/>
{/each}
{#if row.service}
<ServiceLine text={row.service} />
{/if}
</div>
{:else}
<div class="group flex min-w-0 max-w-full flex-col">
{#each row.steps as step (step.key)}
{@const block = step.block}
{#if step.stampedAt}
<span
class="tabular px-1 pt-1.5 text-[11px] text-muted-foreground opacity-60 transition-opacity group-hover:opacity-100"
title={fmtDateTime(step.stampedAt)}
>
{minuteOf(step.stampedAt)}
</span>
{/if}
{#if block.type === "text" && block.text}
<Markdown
class={cn(
"min-w-0 @md:max-w-[75ch] max-w-full",
message.role === "user"
? "rounded-2xl rounded-br-md bg-primary/10 px-3.5 py-2"
: "px-1 py-1"
)}
eager={index >= messages.length - EAGER_MARKDOWN}
class="min-w-0 @md:max-w-[75ch] max-w-full px-1 py-1"
eager={step.eager}
text={block.text}
/>
{:else if block.type === "tool_use"}
<p
class="flex min-w-0 max-w-full items-baseline gap-2 px-1 text-xs"
>
<span class="shrink-0 font-medium"
>{toolLabel(block.name ?? "?")}</span
>
<span class="min-w-0 truncate text-muted-foreground">
{clip(summarizeInput(block.name ?? "", block.input), 160)}
</span>
</p>
{:else if block.type === "tool_result" && showResults}
<pre
class={cn(
"max-h-48 max-w-full overflow-auto whitespace-pre-wrap break-all rounded-md p-2 text-xs",
block.is_error
? "bg-destructive/8 text-destructive"
: "bg-muted/40"
)}
>{clip(resultText(block), RESULT_CLIP)}</pre>
{@const spoken = said(block)}
{#if spoken}
<div class="py-1"><SayBubble text={spoken} /></div>
{:else}
<ToolNodeView node={nodeOf(block)} nodes={NO_NODES} now={0} />
{/if}
{:else if block.type === "thinking" && block.thinking?.trim()}
<Reasoning text={block.thinking} />
{:else if block.type === "thinking"}
<p class="px-1 text-kind-deep text-xs">thinking</p>
<p class="px-1.5 py-1 text-kind-deep text-xs">thinking</p>
{/if}
{/each}
</div>
+18
View File
@@ -0,0 +1,18 @@
const FENCE_HEAD =
/^((?:\s*(?:[-+*]|\d+[.)])\s+|\s*>)*\s*)(`{3,}|~{3,})[ \t]*([^\s`]+)(.*)$/gm;
export function inertFences(
text: string,
runs: (language: string) => boolean
): string {
return text.replace(
FENCE_HEAD,
(
line: string,
lead: string,
fence: string,
language: string,
rest: string
) => (runs(language.toLowerCase()) ? `${lead}${fence}text${rest}` : line)
);
}
+5 -1
View File
@@ -11,7 +11,11 @@ export interface PanelHost {
// Where the index and the last threads persist between opens.
cache?: PanelCache;
// Renders ``text`` into ``node``; returns the cleanup. Absent: marked + DOMPurify.
markdown?: (node: HTMLElement, text: string) => (() => void) | undefined;
markdown?: (
node: HTMLElement,
text: string,
options?: { inert?: boolean }
) => (() => void) | undefined;
name: "browser" | "obsidian";
openLink: (target: string, kind: LinkKind) => void;
// Opens the note a conversation lives in (its markdown binding).
+8 -2
View File
@@ -7,7 +7,13 @@
text,
class: className = "",
eager = true,
}: { text: string; class?: string; eager?: boolean } = $props();
inert = false,
}: {
text: string;
class?: string;
eager?: boolean;
inert?: boolean;
} = $props();
const NEAR = "1200px";
@@ -50,7 +56,7 @@
const now = (next: string) => {
dispose?.();
node.classList.remove("whitespace-pre-wrap");
dispose = host.markdown?.(node, next);
dispose = host.markdown?.(node, next, { inert });
};
const later = (next: string) => {
pending = next;
+49
View File
@@ -0,0 +1,49 @@
<script lang="ts">
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
import { cn } from "$lib/utils";
let { text, live = false }: { text: string; live?: boolean } = $props();
const PREVIEW = 400;
const SPACES = /\s+/g;
let expanded = $state(false);
const flat = $derived(text.replace(SPACES, " ").trim());
</script>
<div class="flex min-w-0 max-w-full flex-col">
<button
aria-expanded={expanded}
class="row-hover ledger-grid grid-cols-[auto_auto_minmax(0,1fr)] rounded-md px-1.5 py-1 text-left text-sm"
onclick={() => {
expanded = !expanded;
}}
type="button"
>
<ChevronRightIcon
class={cn(
"size-3.5 text-icon transition-transform duration-150",
expanded && "rotate-90"
)}
/>
<span class="size-2 rounded-full bg-kind-deep"></span>
<span class="flex min-w-0 items-baseline gap-2">
<span class="shrink-0 font-medium text-kind-deep">reasoning</span>
{#if live && !expanded}
<span class="truncate text-left text-muted-foreground" dir="rtl">
<bdi dir="ltr">{flat.slice(-PREVIEW)}</bdi>
</span>
{:else if !expanded}
<span class="truncate text-muted-foreground"
>{flat.slice(0, PREVIEW)}</span
>
{/if}
</span>
</button>
{#if expanded}
<p
class="mb-1 ml-7 @md:max-w-[75ch] whitespace-pre-wrap break-words text-muted-foreground text-sm"
>
{text.trim()}
</p>
{/if}
</div>
+11
View File
@@ -0,0 +1,11 @@
<script lang="ts">
import MessageSquareIcon from "@lucide/svelte/icons/message-square";
import Markdown from "./markdown.svelte";
let { text }: { text: string } = $props();
</script>
<div class="mx-1 flex gap-2 rounded-md bg-primary/8 px-2.5 py-1.5">
<MessageSquareIcon class="mt-1 size-3.5 shrink-0 text-link" />
<Markdown class="min-w-0 flex-1" {text} />
</div>
+28
View File
@@ -0,0 +1,28 @@
<script lang="ts">
import { clip } from "$lib/format";
import { cn } from "$lib/utils";
let { text, class: className = "" }: { text: string; class?: string } =
$props();
const HEAD_CLIP = 120;
let expanded = $state(false);
</script>
<button
aria-expanded={expanded}
class={cn(
"min-w-0 @md:max-w-[75ch] max-w-full rounded-md px-2 py-1 text-left font-mono text-muted-foreground text-xs hover:bg-accent",
className
)}
onclick={() => {
expanded = !expanded;
}}
type="button"
>
{#if expanded}
<span class="whitespace-pre-wrap break-words">{text}</span>
{:else}
<span class="block truncate">{clip(text.split("\n")[0], HEAD_CLIP)}</span>
{/if}
</button>
+6 -1
View File
@@ -33,6 +33,11 @@
running: "bg-signal animate-pulse-dot",
};
const SHOW_DURATION_AFTER_MS = 2000;
const timed = $derived(
node.status === "running"
? duration >= SHOW_DURATION_AFTER_MS
: node.endedAt !== null
);
</script>
<div class={cn("flex flex-col", depth > 0 && "ml-3 border-l pl-2")}>
@@ -59,7 +64,7 @@
<span class="truncate text-muted-foreground">{summary}</span>
{/if}
</span>
{#if node.status !== "running" || duration >= SHOW_DURATION_AFTER_MS}
{#if timed}
<span class="tabular text-muted-foreground text-xs">
{fmtDuration(duration)}
</span>
+8 -6
View File
@@ -1,5 +1,4 @@
<script lang="ts">
import MessageSquareIcon from "@lucide/svelte/icons/message-square";
import StatusPill from "$lib/components/status-pill.svelte";
import {
elapsedMs,
@@ -11,6 +10,8 @@
import { cn } from "$lib/utils";
import { isLive, ORIGIN_LABELS, type Turn } from "./activity.svelte";
import Markdown from "./markdown.svelte";
import Reasoning from "./reasoning.svelte";
import SayBubble from "./say-bubble.svelte";
import ToolNodeView from "./tool-node.svelte";
let { turn, now }: { turn: Turn; now: number } = $props();
@@ -59,7 +60,7 @@
tool {toolCount === 1 ? "call" : "calls"}
</span>
{/if}
{#if turn.thinking > 0 && turn.status === "running"}
{#if turn.thinking > 0 && turn.status === "running" && !turn.reasoning}
<span class="text-kind-deep">thinking…</span>
{/if}
{#if turn.usage}
@@ -79,9 +80,13 @@
{:else if turn.userText}
<Markdown
class="mx-1 rounded-md bg-muted/50 px-2.5 py-1.5"
inert
text={turn.userText}
/>
{/if}
{#if turn.reasoning.trim()}
<Reasoning live={turn.status === "running"} text={turn.reasoning} />
{/if}
{#if turn.roots.length > 0}
<div class="flex flex-col">
{#each turn.roots as rootId (rootId)}
@@ -92,10 +97,7 @@
</div>
{/if}
{#each turn.says as text, index (index)}
<div class="mx-1 flex gap-2 rounded-md bg-primary/8 px-2.5 py-1.5">
<MessageSquareIcon class="mt-1 size-3.5 shrink-0 text-link" />
<Markdown class="min-w-0 flex-1" {text} />
</div>
<SayBubble {text} />
{/each}
{#if turn.text}
<Markdown class="mx-1 px-1" text={turn.text} />
+68
View File
@@ -0,0 +1,68 @@
<script lang="ts">
import type { ProcessRow } from "$lib/api/types";
import { Button } from "$lib/components/ui/button";
import { fmtBytes, fmtCpu, fmtSeconds } from "$lib/format";
let {
rows,
onKill,
}: { rows: ProcessRow[]; onKill: (row: ProcessRow) => void } = $props();
const INDENT_REM = 0.875;
</script>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="text-muted-foreground text-xs">
<tr class="border-b text-left">
<th class="py-1.5 pr-3 font-medium">pid</th>
<th class="w-full py-1.5 pr-3 font-medium">command</th>
<th class="py-1.5 pr-3 text-right font-medium">cpu</th>
<th class="py-1.5 pr-3 text-right font-medium">rss</th>
<th class="hidden py-1.5 pr-3 text-right font-medium sm:table-cell">
age
</th>
<th class="py-1.5"><span class="sr-only">actions</span></th>
</tr>
</thead>
<tbody>
{#each rows as row (row.pid)}
<tr class="border-b last:border-b-0">
<td class="tabular py-1 pr-3 text-muted-foreground text-xs">
{row.pid}
</td>
<td class="max-w-0 py-1 pr-3">
<span
class="block truncate font-mono text-xs"
title={row.command}
style:padding-left="{row.depth * INDENT_REM}rem"
>
{row.command || row.name}
</span>
</td>
<td class="tabular py-1 pr-3 text-right text-xs">
{fmtCpu(row.cpu)}
</td>
<td class="tabular whitespace-nowrap py-1 pr-3 text-right text-xs">
{fmtBytes(row.rss)}
</td>
<td
class="tabular hidden whitespace-nowrap py-1 pr-3 text-right text-muted-foreground text-xs sm:table-cell"
>
{fmtSeconds(row.age_seconds)}
</td>
<td class="py-0.5 text-right">
<Button
class="text-muted-foreground hover:text-destructive max-sm:h-10"
onclick={() => onKill(row)}
size="xs"
variant="ghost"
>
Kill
</Button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
+150
View File
@@ -0,0 +1,150 @@
<script lang="ts">
import { onMount, untrack } from "svelte";
import { base } from "$app/paths";
import type { ApiClient } from "$lib/api/client";
import type { ProcessRow, SessionRow } from "$lib/api/types";
import { Button } from "$lib/components/ui/button";
import { shortId } from "$lib/format";
import { ConversationFeed } from "$lib/panel/conversation.svelte";
import TurnCard from "$lib/panel/turn-card.svelte";
import ProcessTable from "./process-table.svelte";
let {
client,
row,
busy,
note,
onInterrupt,
onClose,
onKill,
}: {
client: ApiClient;
row: SessionRow;
busy: boolean;
note: string;
onInterrupt: (row: SessionRow) => void;
onClose: (row: SessionRow) => void;
onKill: (process: ProcessRow) => void;
} = $props();
const TICK_MS = 1000;
const feed = new ConversationFeed(
() => client,
untrack(() => row.key)
);
let stderr = $state<string[] | null>(null);
let now = $state(Date.now());
const turn = $derived(feed.model.running);
onMount(() => {
feed.start();
const timer = setInterval(() => {
now = Date.now();
}, TICK_MS);
return () => {
feed.stop();
clearInterval(timer);
};
});
$effect(() => {
const { key, idle_seconds: polled } = row;
if (polled >= 0) {
client
.sessionStderr(key)
.then((tail) => {
stderr = tail.lines;
})
.catch(() => undefined);
}
});
</script>
<div class="flex flex-col gap-4 px-3 pt-2 pb-4">
<div class="flex flex-wrap items-center gap-x-4 gap-y-2">
<p class="tabular min-w-0 flex-1 text-muted-foreground text-xs">
session
{shortId(row.session_id)}
{#if row.pid}
· pid {row.pid}
{/if}
{#if row.pending > 0}
· {row.pending}
buffered
{/if}
</p>
<div class="flex flex-wrap items-center gap-2">
{#if note}
<span class="text-muted-foreground text-xs" role="status">{note}</span>
{/if}
{#if !feed.error}
<Button
class="max-sm:h-10"
href="{base}/conversations/{row.key}"
size="sm"
variant="ghost"
>
Open thread
</Button>
{/if}
<Button
class="max-sm:h-10"
disabled={busy || !row.busy}
onclick={() => onInterrupt(row)}
size="sm"
variant="outline"
>
Interrupt turn
</Button>
<Button
class="max-sm:h-10"
disabled={busy}
onclick={() => onClose(row)}
size="sm"
variant="destructive"
>
Close session
</Button>
</div>
</div>
<section class="flex flex-col gap-1">
<h4 class="label-quiet">Now</h4>
{#if turn}
<TurnCard {now} {turn} />
{:else}
<p class="text-muted-foreground text-sm">
{row.busy
? "A turn is running; its events have not reached this page yet."
: "Nothing in flight."}
</p>
{/if}
</section>
<section class="flex flex-col gap-1">
<h4 class="label-quiet">Process tree</h4>
{#if row.processes.length === 0}
<p class="text-muted-foreground text-sm">
The process is not visible to the gateway.
</p>
{:else}
<ProcessTable {onKill} rows={row.processes} />
{/if}
</section>
<section class="flex flex-col gap-1">
<h4 class="label-quiet">stderr</h4>
{#if stderr?.length === 0}
<p class="text-muted-foreground text-sm">
Nothing on stderr since this process started.
</p>
{:else if stderr}
<pre
class="max-h-64 overflow-auto whitespace-pre-wrap break-all rounded-md bg-muted/40 p-2 text-xs"
>{stderr.join(
"\n"
)}</pre>
{/if}
</section>
</div>
+324 -76
View File
@@ -1,16 +1,39 @@
<script lang="ts">
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
import { onMount } from "svelte";
import { base } from "$app/paths";
import type { SessionsResponse } from "$lib/api/types";
import type {
ProcessRow,
SessionRow,
SessionsResponse,
} from "$lib/api/types";
import ErrorNote from "$lib/components/error-note.svelte";
import { fmtBytes, fmtSeconds, shortId } from "$lib/format";
import { Button } from "$lib/components/ui/button";
import * as Dialog from "$lib/components/ui/dialog";
import { fmtBytes, fmtCpu, fmtSeconds, shortId } from "$lib/format";
import { gateway } from "$lib/gateway.svelte";
import { session } from "$lib/session.svelte";
import KindMark from "$lib/shell/kind-mark.svelte";
import ProcessTable from "$lib/system/process-table.svelte";
import SessionDetail from "$lib/system/session-detail.svelte";
import { cn } from "$lib/utils";
const POLL_MS = 10_000;
interface Order {
confirm: string;
lines: string[];
run: () => Promise<unknown>;
title: string;
}
const POLL_MS = 3000;
const SECOND_LOOK_FOR_CPU_MS = 1000;
const COLUMNS = 8;
let sessions = $state<SessionsResponse | null>(null);
let failure = $state<string | null>(null);
let unfolded = $state<Set<string>>(new Set());
let order = $state<Order | null>(null);
let orderFailure = $state<string | null>(null);
let notes = $state<Record<string, string>>({});
let busy = $state(false);
async function load() {
const { client } = session;
@@ -25,90 +48,315 @@
}
}
function toggle(key: string) {
const next = new Set(unfolded);
if (!next.delete(key)) {
next.add(key);
}
unfolded = next;
}
function ask(next: Order) {
orderFailure = null;
order = next;
}
async function carryOut(pending: Order) {
busy = true;
try {
await pending.run();
order = null;
await load();
} catch (cause) {
orderFailure = cause instanceof Error ? cause.message : String(cause);
} finally {
busy = false;
}
}
async function interrupt(row: SessionRow) {
const { client } = session;
if (!client) {
return;
}
busy = true;
try {
const { interrupted } = await client.interruptSession(row.key);
notes = {
...notes,
[row.key]: interrupted ? "" : "Nothing was running.",
};
await load();
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
notes = { ...notes, [row.key]: message };
} finally {
busy = false;
}
}
function askClose(row: SessionRow) {
const { client } = session;
if (!client) {
return;
}
const lines = [
"The claude process exits. The conversation stays and resumes from its transcript on the next message.",
];
if (row.busy) {
lines.push("A turn is running: it is cut and its reply is lost.");
}
if (row.pinned) {
lines.push("This session is pinned: the gateway never closes it itself.");
}
ask({
confirm: "Close session",
lines,
run: () => client.closeSession(row.key),
title: `Close the ${row.kind} session?`,
});
}
function askKill(process: ProcessRow) {
const { client } = session;
if (!client) {
return;
}
const owner = sessions?.sessions.find((row) => row.pid === process.pid);
const lines = [
`SIGKILL to ${process.name} and everything it started. Nothing gets to clean up.`,
];
if (owner) {
lines.push(
"It is a session's claude: the session is dropped, and the conversation resumes from its transcript on the next message."
);
}
ask({
confirm: "Kill",
lines,
run: () => client.killProcess(process.pid),
title: `Kill process ${process.pid}?`,
});
}
const titles = $derived(
new Map(gateway.conversations.map((row) => [row.id, row.title]))
);
onMount(() => {
load();
const second = setTimeout(load, SECOND_LOOK_FOR_CPU_MS);
const poll = setInterval(load, POLL_MS);
return () => clearInterval(poll);
return () => {
clearTimeout(second);
clearInterval(poll);
};
});
</script>
<section class="flex flex-col gap-3">
<div class="flex items-baseline gap-3">
<h2 class="font-medium text-sm">Live claude processes</h2>
{#if sessions}
<span class="tabular text-muted-foreground text-xs">
{sessions.sessions.length}
· {fmtBytes(sessions.rss)}
{#if sessions.rss_limit}
of {fmtBytes(sessions.rss_limit)}
{/if}
</span>
{/if}
</div>
{#snippet state(s: SessionRow)}
<span class={cn(s.busy ? "text-signal" : "text-muted-foreground")}>
{s.busy ? "busy" : "idle"}
</span>
{#if s.pinned}
<span class="ml-1 text-muted-foreground">pinned</span>
{/if}
{#if s.dirty}
<span class="ml-1 text-warn">dirty</span>
{/if}
{#if s.pending_question}
<span class="ml-1 text-attention-foreground">question</span>
{/if}
{/snippet}
<div class="flex flex-wrap items-baseline gap-3">
<h2 class="font-medium text-sm">Live claude processes</h2>
{#if sessions}
<span class="tabular text-muted-foreground text-xs">
{sessions.sessions.length}
{sessions.sessions.length === 1 ? "session" : "sessions"}
· {fmtBytes(sessions.rss)}
{#if sessions.rss_limit}
of {fmtBytes(sessions.rss_limit)}
{/if}
· cpu {fmtCpu(sessions.cpu)}
</span>
{/if}
</div>
<div class="flex flex-col gap-6">
{#if failure}
<ErrorNote message={failure} retry={load} />
{:else if !sessions}
<p class="text-muted-foreground text-sm">Loading…</p>
{:else if sessions.sessions.length === 0}
<p class="text-muted-foreground text-sm">
No claude processes alive. Sessions spawn on the first turn and are reaped
by idle time or memory pressure.
</p>
{:else}
<div class="overflow-x-auto rounded-lg border bg-strip">
<table class="w-full text-sm">
<thead class="text-muted-foreground text-xs">
<tr class="border-b text-left">
<th class="px-3 py-2 font-medium">conversation</th>
<th class="px-3 py-2 font-medium">agent</th>
<th class="px-3 py-2 font-medium">state</th>
<th class="px-3 py-2 text-right font-medium">rss</th>
<th class="px-3 py-2 text-right font-medium">idle</th>
<th class="px-3 py-2 text-right font-medium">age</th>
<th class="px-3 py-2 text-right font-medium">turns</th>
</tr>
</thead>
<tbody>
{#each sessions.sessions as s (s.key)}
<tr class="row-hover border-b last:border-b-0">
<td class="px-3 py-1.5">
<a
class="flex items-center gap-2 hover:underline"
href="{base}/conversations/{s.key}"
<section class="flex flex-col gap-2">
<h3 class="label-quiet">Sessions</h3>
{#if sessions.sessions.length === 0}
<p class="text-muted-foreground text-sm">
No claude processes alive. Sessions spawn on the first turn and are
reaped by idle time or memory pressure.
</p>
{:else}
<div class="overflow-x-auto rounded-lg border bg-strip">
<table class="w-full text-sm">
<thead class="text-muted-foreground text-xs">
<tr class="border-b text-left">
<th class="px-3 py-2 font-medium">conversation</th>
<th class="hidden px-3 py-2 font-medium sm:table-cell">
agent
</th>
<th class="hidden px-3 py-2 font-medium sm:table-cell">
state
</th>
<th class="px-3 py-2 text-right font-medium">cpu</th>
<th class="px-3 py-2 text-right font-medium">rss</th>
<th
class="hidden px-3 py-2 text-right font-medium sm:table-cell"
>
<KindMark kind={s.kind} />
<span class="tabular text-xs">{shortId(s.key)}</span>
</a>
</td>
<td class="px-3 py-1.5">{s.agent}</td>
<td class="px-3 py-1.5 text-xs">
<span
class={cn(s.busy ? "text-signal" : "text-muted-foreground")}
idle
</th>
<th
class="hidden px-3 py-2 text-right font-medium sm:table-cell"
>
{s.busy ? "busy" : "idle"}
</span>
{#if s.pinned}
<span class="ml-1 text-muted-foreground">pinned</span>
age
</th>
<th
class="hidden px-3 py-2 text-right font-medium sm:table-cell"
>
turns
</th>
</tr>
</thead>
<tbody>
{#each sessions.sessions as s (s.key)}
{@const expanded = unfolded.has(s.key)}
<tr class={cn("border-b", !expanded && "last:border-b-0")}>
<td class="max-w-0 px-1.5 py-1 sm:w-2/5">
<button
aria-expanded={expanded}
class="row-hover flex max-w-full items-center gap-2 rounded-md px-1.5 py-1 text-left max-sm:min-h-10"
onclick={() => toggle(s.key)}
type="button"
>
<ChevronRightIcon
class={cn(
"size-3.5 text-icon transition-transform duration-150",
expanded && "rotate-90"
)}
/>
<KindMark kind={s.kind} />
<span class="flex min-w-0 flex-col">
<span class="truncate font-medium">
{titles.get(s.key) ?? shortId(s.key)}
</span>
<span class="text-xs sm:hidden">
{@render state(s)}
</span>
</span>
</button>
</td>
<td class="hidden px-3 py-1.5 sm:table-cell">{s.agent}</td>
<td
class="hidden whitespace-nowrap px-3 py-1.5 text-xs sm:table-cell"
>
{@render state(s)}
</td>
<td class="tabular px-3 py-1.5 text-right">
{fmtCpu(s.cpu)}
</td>
<td class="tabular whitespace-nowrap px-3 py-1.5 text-right">
{fmtBytes(s.rss)}
</td>
<td
class="tabular hidden px-3 py-1.5 text-right sm:table-cell"
>
{fmtSeconds(s.idle_seconds)}
</td>
<td
class="tabular hidden px-3 py-1.5 text-right sm:table-cell"
>
{fmtSeconds(s.age_seconds)}
</td>
<td
class="tabular hidden px-3 py-1.5 text-right sm:table-cell"
>
{s.turns}
</td>
</tr>
{#if expanded && session.client}
<tr class="border-b last:border-b-0">
<td class="bg-background/60" colspan={COLUMNS}>
<SessionDetail
{busy}
client={session.client}
note={notes[s.key] ?? ""}
onClose={askClose}
onInterrupt={interrupt}
onKill={askKill}
row={s}
/>
</td>
</tr>
{/if}
{#if s.dirty}
<span class="ml-1 text-warn">dirty</span>
{/if}
{#if s.pending_question}
<span class="ml-1 text-attention-foreground">question</span>
{/if}
</td>
<td class="tabular px-3 py-1.5 text-right">{fmtBytes(s.rss)}</td>
<td class="tabular px-3 py-1.5 text-right">
{fmtSeconds(s.idle_seconds)}
</td>
<td class="tabular px-3 py-1.5 text-right">
{fmtSeconds(s.age_seconds)}
</td>
<td class="tabular px-3 py-1.5 text-right">{s.turns}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
<section class="flex flex-col gap-2">
<h3 class="label-quiet">Outside any session</h3>
{#if sessions.others.length === 0}
<p class="text-muted-foreground text-sm">
Nothing else runs under the gateway.
</p>
{:else}
<ProcessTable onKill={askKill} rows={sessions.others} />
{/if}
</section>
{/if}
</section>
</div>
<Dialog.Root
onOpenChange={(next) => {
if (!next) {
order = null;
}
}}
open={order !== null}
>
<Dialog.Content>
{#if order}
{@const pending = order}
<Dialog.Header>
<Dialog.Title>{pending.title}</Dialog.Title>
{#each pending.lines as line (line)}
<Dialog.Description>{line}</Dialog.Description>
{/each}
{#if orderFailure}
<p class="text-destructive text-sm" role="alert">{orderFailure}</p>
{/if}
</Dialog.Header>
<Dialog.Footer>
<Button
disabled={busy}
onclick={() => {
order = null;
}}
variant="ghost"
>
Cancel
</Button>
<Button
disabled={busy}
onclick={() => carryOut(pending)}
variant="destructive"
>
{pending.confirm}
</Button>
</Dialog.Footer>
{/if}
</Dialog.Content>
</Dialog.Root>
Generated
+7 -8
View File
@@ -338,7 +338,7 @@ requires-dist = [
{ name = "anthropic", specifier = ">=0.103.0" },
{ name = "anyio", specifier = ">=4.13.0" },
{ name = "argon2-cffi", specifier = ">=25.1.0" },
{ name = "claude-agent-sdk", specifier = ">=0.2.150" },
{ name = "claude-agent-sdk", specifier = ">=0.2.157" },
{ name = "croniter", specifier = ">=6.2.4" },
{ name = "fastapi", specifier = ">=0.136.1" },
{ name = "fastmcp", specifier = ">=3.3.1" },
@@ -450,7 +450,7 @@ wheels = [
[[package]]
name = "claude-agent-sdk"
version = "0.2.150"
version = "0.2.157"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@@ -458,13 +458,12 @@ dependencies = [
{ name = "mcp" },
{ name = "sniffio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6d/1e/99bababecf24a4a55a0775665ff0be010aff26f653fc9be2f4f17a0b5ef0/claude_agent_sdk-0.2.150.tar.gz", hash = "sha256:3856e932f8d45514ddf5ddd540fb844678bb11638cabf9b939ce8b708e4eb352", size = 344692, upload-time = "2026-09-01T18:07:15.284Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b4/e3/444df1691c864a8349067686719b43e714fc43d3853fb1b2ebc2097e2a6e/claude_agent_sdk-0.2.157.tar.gz", hash = "sha256:4ce6f41bc965fcb4a9d11bd7a3cac266975c75b632f3d74a26f55f0fffb3160a", size = 346579, upload-time = "2026-09-18T18:21:10.306Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c3/13/b5b86d90c95e8a691143eeb45bbf2e1c250324817980464d6ea63ef0f789/claude_agent_sdk-0.2.150-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8f58d746f3552faeac52883b5a502a8d9b5aef067ea0b68fae5c9bae0f39d73d", size = 85396845, upload-time = "2026-09-01T18:07:19.074Z" },
{ url = "https://files.pythonhosted.org/packages/d9/8f/c17da4fa9cd8bddb8ac1538456dd0fde3a43abb1dfc28ca448f85e2a10d3/claude_agent_sdk-0.2.150-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:fee3ae2dd457742d82070b5fa122070ee8e2e80b4f9b37a57ff0a8261ade656d", size = 89904483, upload-time = "2026-09-01T18:07:22.671Z" },
{ url = "https://files.pythonhosted.org/packages/8d/95/5b33717b807c6fbe29fca55a246eab9a09a80c9dd288ffeb31f05681b7fc/claude_agent_sdk-0.2.150-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:813535a0267271d91af29c456ec26137b34a8b7bc066647ea59efba6e3c26c93", size = 95270310, upload-time = "2026-09-01T18:07:26.61Z" },
{ url = "https://files.pythonhosted.org/packages/9f/de/5a8b59e6a713ea3f4be88816cfd487b1a5b08c8ad9f042abe56d2decc184/claude_agent_sdk-0.2.150-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:cb65ff33060baa098b420232c630fa287d0f95e9469e7ceb706d0b7d8841888e", size = 95460277, upload-time = "2026-09-01T18:07:31.092Z" },
{ url = "https://files.pythonhosted.org/packages/dd/42/d147578a0df16c1b4b0deaebfb3d25e590ad5c16cb6b59a68f9dbb618be3/claude_agent_sdk-0.2.150-py3-none-win_amd64.whl", hash = "sha256:3b517a21c723854de778b38fa51660c5b68ee5fb6f28b2abf291a51471a7b378", size = 98078644, upload-time = "2026-09-01T18:07:35.734Z" },
{ url = "https://files.pythonhosted.org/packages/ec/e8/3e9cc23eb5a5a01a0e2ee931a17db2785f95407525d50366af06a4a5469c/claude_agent_sdk-0.2.157-py3-none-macosx_11_0_arm64.whl", hash = "sha256:dd6a2c7aa1901e43b827411d62f2327aed82f53ed8ca6b076eaa5be4a1fc681f", size = 92744001, upload-time = "2026-09-18T18:21:15.969Z" },
{ url = "https://files.pythonhosted.org/packages/49/eb/e5095703a8efbe504ec468cab039543caabfc0ea3327c735a3d1b60e57ac/claude_agent_sdk-0.2.157-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:ebf3f514c61db9be01bbf6d1acb0d993247340583b3559d2df75e3822f618bbb", size = 97352326, upload-time = "2026-09-18T18:21:21.007Z" },
{ url = "https://files.pythonhosted.org/packages/d6/f4/64505beb3c757b863753d6556539554704ec9168cb96454dc6463b14df85/claude_agent_sdk-0.2.157-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:a58089e0c11364db2ec7a139e09b36a4fb9c95817a758bee7b28e0434f55c0b6", size = 102701030, upload-time = "2026-09-18T18:21:27.065Z" },
{ url = "https://files.pythonhosted.org/packages/03/8f/2a7c121469fd16d1d6465bec180726c54f6e151b12e50835283b4285d1ce/claude_agent_sdk-0.2.157-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:bd126b2edee3a247b2c7c4cee1f5a3ea68ef748288ec0650db88dd3918e5471a", size = 102830653, upload-time = "2026-09-18T18:21:32.948Z" },
]
[[package]]