Files
2026-07-13 13:12:00 +08:00

786 lines
33 KiB
Python

"""File + tmux bridge for the qwen-native terminal harness.
Unlike goose-/cursor-native (which simulate keystrokes via ``tmux send-keys``),
``qwen`` ships a built-in remote-control protocol that we drive through two files
the TUI is launched against:
- ``--input-file`` (:func:`input_file_path`): ``qwen`` ``watchFile``\\s it and
parses appended JSONL commands. We append ``{"type":"submit","text":...}`` to
deliver a web-UI turn (qwen routes it through the *same* ``submitQuery`` path
the keyboard uses, so it renders in the TUI transcript) and
``{"type":"confirmation_response","request_id":...,"allowed":...}`` to answer a
tool-approval request.
- ``--json-file`` (:func:`events_file_path`): ``qwen`` streams structured JSON
events here while the TUI renders normally; :mod:`omnigent.qwen_native_forwarder`
tails it.
The runner launches the TUI in a runner-owned tmux pane (for the embedded
display) and records that pane via :func:`write_tmux_target`. Message injection
is file-based, but two affordances still go through the pane because qwen's
input-file watcher has no command for them: **interrupt** (Stop → ``Escape``, see
:func:`inject_interrupt`) and **hard stop** (:func:`kill_session`).
Verified against ``qwen`` v0.18.1 (``RemoteInputWatcher`` + dual-output). See
``docs/QWEN_NATIVE_DESIGN.md``.
"""
from __future__ import annotations
import contextlib
import hashlib
import json
import os
import re
import secrets
import socket
import subprocess
import sys
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
#: Env var carrying the bridge dir into the harness executor process.
BRIDGE_DIR_ENV_VAR = "HARNESS_QWEN_NATIVE_BRIDGE_DIR"
#: Fixed namespace for deriving a stable qwen ``--session-id`` from an Omnigent
#: conversation id (UUIDv5). Never change it — it would orphan every existing
#: qwen recording (resume would mint a new id and lose history).
_QWEN_SESSION_NAMESPACE = uuid.UUID("6b6f3d2e-9a1c-5e84-bf0a-1d7c5a2e9f43")
_BRIDGE_ROOT = Path(os.environ.get("TMPDIR", "/tmp")) / f"omnigent-{os.getuid()}" / "qwen-native"
_TMUX_FILE = "tmux.json"
#: JSONL command file qwen watches (``--input-file``); we append to it.
_INPUT_FILE = "qwen_in.jsonl"
#: NDJSON event file qwen writes (``--json-file``); the forwarder tails it.
_EVENTS_FILE = "qwen_out.ndjson"
_TMUX_READY_TIMEOUT_S = 30.0
_TMUX_SEND_TIMEOUT_S = 10.0
_POLL_INTERVAL_S = 0.2
#: Token config the shared Omnigent MCP relay (``serve-mcp``) reads from the
#: bridge dir. Mirrors cursor-/claude-native (``cursor_native_bridge.py``).
_BRIDGE_CONFIG_FILE = "bridge.json"
#: Name qwen lists the Omnigent MCP server under (shows in ``/mcp``).
_MCP_SERVER_NAME = "omnigent"
#: Per-session MCP config passed to qwen via ``--mcp-config <path>``. Lives in
#: the bridge dir (NOT the workspace), so we never drop a file in the user's repo
#: and concurrent same-workspace sessions can't collide. CLI-provided MCP servers
#: are also ungated (no "Untrusted MCP server" prompt), unlike a project
#: ``.mcp.json`` / ``.qwen/settings.json``. The claude-native ``--mcp-config``
#: model (it writes no workspace file either).
_MCP_CONFIG_FILE = "mcp_config.json"
#: qwen version string stamped on synthesized recording records + sidecars. Must
#: be a version qwen's resume loader accepts; verified loadable on qwen v0.18.2.
_QWEN_SYNTH_VERSION = "0.18.2"
#: ``contextWindowSize`` stamped on synthesized assistant records. Informational
#: only — the live resume uses the resolved model's real window.
_QWEN_SYNTH_CONTEXT_WINDOW = 131072
def bridge_dir_for_session_id(session_id: str) -> Path:
"""Return the per-session bridge dir, e.g. ``/tmp/omnigent-<uid>/qwen-native/<hash>``."""
digest = hashlib.sha256(session_id.encode("utf-8")).hexdigest()[:32]
return _BRIDGE_ROOT / digest
def bridge_root() -> Path:
"""Return the configured qwen-native bridge root."""
return _BRIDGE_ROOT
def qwen_session_id_for_conversation(conversation_id: str) -> str:
"""Return the deterministic qwen ``--session-id`` for an Omnigent conversation.
UUIDv5 of the conversation id: stable across resumes (recomputable, never
stored) and a valid UUID (qwen requires one). The runner launches a fresh
session with ``--session-id <this>`` and later restores it with
``--resume <this>`` so the qwen TUI shows the prior conversation on resume.
:param conversation_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:returns: A stable UUID string usable as qwen's session id.
"""
return str(uuid.uuid5(_QWEN_SESSION_NAMESPACE, conversation_id))
def _qwen_project_slug(workspace: Path | str) -> str:
"""Return qwen's per-project directory slug for *workspace*.
qwen keys its on-disk session store by project: the cwd's real path with
every non-alphanumeric character replaced by ``-`` (verified against qwen
v0.18.1 — e.g. ``/private/tmp/qwen_x`` → ``-private-tmp-qwen-x``). Uses
``realpath`` because the runner launches qwen with ``cwd=realpath(workspace)``
and qwen records under ``process.cwd()``.
"""
real = os.path.realpath(str(workspace))
return re.sub(r"[^A-Za-z0-9]", "-", real)
def qwen_session_recording_path(session_id: str, workspace: Path | str) -> Path:
"""Return the path to qwen's on-disk chat recording for *session_id*.
``~/.qwen/projects/<project-slug>/chats/<session-id>.jsonl`` — the JSONL
qwen appends interactive-session events to (``--chat-recording``, on by
default), scoped to *workspace*'s project slug. The file may not exist yet
(a fresh session creates it on first event). Used both to gate ``--resume``
(:func:`qwen_session_recording_exists`) and to tail for the
``chat_compression`` marker (see :mod:`omnigent.qwen_native_forwarder`).
"""
return (
Path.home()
/ ".qwen"
/ "projects"
/ _qwen_project_slug(workspace)
/ "chats"
/ f"{session_id}.jsonl"
)
def qwen_session_recording_exists(session_id: str, workspace: Path | str) -> bool:
"""Return whether qwen has an on-disk chat recording for *session_id* in *workspace*.
qwen records interactive sessions (``--chat-recording``, on by default) to
``~/.qwen/projects/<project-slug>/chats/<session-id>.jsonl`` and resolves
``--resume <id>`` **relative to the current project** (cwd slug) — not
globally. So the check must be scoped to the *launch workspace's* slug: a
glob across all projects would report a recording made under workspace A as
present when resuming from workspace B, choosing ``--resume`` and landing the
user on qwen's blocking "No saved session found with ID" error screen — the
exact failure this guard exists to prevent (moved/renamed repo, or resume
from a different cwd). The runner uses this to choose ``--resume`` (recording
present here) vs ``--session-id`` (fresh). A false negative (slug drift) only
degrades to a clean fresh launch, never the blocking error.
:param session_id: A qwen session id (see :func:`qwen_session_id_for_conversation`).
:param workspace: The cwd qwen will be (re)launched in.
:returns: ``True`` if a recording for *session_id* exists under *workspace*'s
qwen project dir.
"""
try:
return qwen_session_recording_path(session_id, workspace).is_file()
except OSError:
return False
def _qwen_iso_now() -> str:
"""Return the current UTC time as a qwen-style ISO-8601 millisecond stamp."""
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
def _qwen_synth_uuid(qwen_session_id: str, index: int) -> str:
"""Return a deterministic record uuid for a synthesized qwen recording.
UUIDv5 over the session id + position so re-running the fork rebuild
produces a byte-identical recording (idempotent rebuilds, stable tests)
instead of fresh random ids each launch.
"""
return str(uuid.uuid5(_QWEN_SESSION_NAMESPACE, f"{qwen_session_id}:{index}"))
def _qwen_text_from_api_content(content: object, api_type: str) -> str:
"""Concatenate the text of an Omnigent content array's blocks of *api_type*.
:param content: Omnigent content array, e.g. ``[{"type":"input_text","text":"hi"}]``.
:param api_type: Block type to include, ``"input_text"`` or ``"output_text"``.
:returns: The joined text, or ``""`` when there is none.
"""
if not isinstance(content, list):
return ""
parts: list[str] = []
for block in content:
if isinstance(block, dict) and block.get("type") == api_type:
text = block.get("text")
if isinstance(text, str) and text:
parts.append(text)
return "".join(parts)
def qwen_session_records_from_session_items(
items: list[dict[str, Any]],
*,
qwen_session_id: str,
cwd: Path | str,
model: str = "",
timestamp: str | None = None,
) -> list[dict[str, Any]]:
"""Convert Omnigent session items into qwen chat-recording JSONL records.
qwen's recording is a linked list chained by ``uuid`` / ``parentUuid``. We
emit only the ``user`` / ``assistant`` message records qwen reconstructs
history from; the ``system`` snapshot records it writes live are telemetry
and not required for ``--resume`` (verified on v0.18.2). Tool calls are
dropped — text turns carry the context a cross-harness fork needs.
Omnigent items map as:
- user ``message`` → ``{"type":"user","message":{"role":"user","parts":[{"text"}]}}``
- assistant ``message`` → ``{"type":"assistant","message":{"role":"model",...}}``
Cancelled turns aren't restored: an interrupted assistant turn and its
response group are skipped (claude/codex/pi share a ``response_id`` across
the turn), and a trailing unanswered user prompt is dropped (covers a
qwen-native source, whose per-event ``response_id`` doesn't group a turn).
:param items: Flat Omnigent item dicts in chronological order.
:param qwen_session_id: qwen session id stamped on every record.
:param cwd: Working directory stamped on records (realpath'd to match the
project slug qwen records under).
:param model: Default model id for assistant records; overridden per-item by
the item's own ``model`` when present.
:param timestamp: ISO stamp for all records; defaults to now. Pass a fixed
value for deterministic output in tests.
:returns: qwen recording record dicts in order (empty if nothing carryable).
"""
ts = timestamp or _qwen_iso_now()
cwd_str = os.path.realpath(str(cwd))
skip_response_ids = {
item.get("response_id")
for item in items
if item.get("type") == "message"
and item.get("role") == "assistant"
and item.get("interrupted") is True
and isinstance(item.get("response_id"), str)
and item.get("response_id")
}
records: list[dict[str, Any]] = []
parent_uuid: str | None = None
for index, item in enumerate(items):
if item.get("type") != "message":
continue
response_id = item.get("response_id")
if isinstance(response_id, str) and response_id in skip_response_ids:
continue
role = item.get("role")
if role == "user":
text = _qwen_text_from_api_content(item.get("content"), "input_text")
if not text:
continue
rec_uuid = _qwen_synth_uuid(qwen_session_id, index)
records.append(
{
"uuid": rec_uuid,
"parentUuid": parent_uuid,
"sessionId": qwen_session_id,
"timestamp": ts,
"type": "user",
"cwd": cwd_str,
"version": _QWEN_SYNTH_VERSION,
"message": {"role": "user", "parts": [{"text": text}]},
}
)
parent_uuid = rec_uuid
elif role == "assistant":
text = _qwen_text_from_api_content(item.get("content"), "output_text")
if not text:
continue
item_model = item.get("model")
eff_model = item_model if isinstance(item_model, str) and item_model else model
rec_uuid = _qwen_synth_uuid(qwen_session_id, index)
records.append(
{
"uuid": rec_uuid,
"parentUuid": parent_uuid,
"sessionId": qwen_session_id,
"timestamp": ts,
"type": "assistant",
"cwd": cwd_str,
"version": _QWEN_SYNTH_VERSION,
"model": eff_model,
"message": {"role": "model", "parts": [{"text": text}]},
"usageMetadata": {
"promptTokenCount": 0,
"candidatesTokenCount": 0,
"totalTokenCount": 0,
},
"contextWindowSize": _QWEN_SYNTH_CONTEXT_WINDOW,
}
)
parent_uuid = rec_uuid
# Drop a trailing unanswered user prompt (a turn cancelled before any reply).
# The response-group skip can't catch it for a qwen-native source, whose
# per-event ``response_id`` (``qwen:<uuid>``) doesn't group a turn; a
# committed transcript otherwise ends on a completed assistant turn.
while records and records[-1]["type"] == "user":
records.pop()
return records
def write_qwen_session_recording(
qwen_session_id: str,
workspace: Path | str,
records: list[dict[str, Any]],
*,
timestamp: str | None = None,
) -> Path:
"""Write a synthesized qwen chat recording (+ discovery sidecars) to disk.
qwen resolves ``--resume <id>`` from THREE files under its per-project dir,
not the ``.jsonl`` alone (verified on v0.18.2 — a bare recording lands the
user on the blocking "No saved session found" screen):
- ``chats/<id>.jsonl`` — the conversation records (*records*).
- ``chats/<id>.runtime.json`` — the session index entry ``sessions list`` /
``--resume`` read to discover the session.
- ``meta.json`` — the project-level marker (created if absent; an existing
one is left untouched so we don't reset another session's ``createdAt``).
All three are written atomically, and the ``.jsonl`` is committed LAST (after
both sidecars): :func:`qwen_session_recording_exists` keys on the ``.jsonl``,
so a failed sidecar write leaves no ``.jsonl`` and the launch degrades to a
clean fresh start, never the blocking "No saved session found" screen.
:param qwen_session_id: qwen session id (file stem + ``session_id`` field).
:param workspace: cwd qwen will resume in; its realpath drives the project slug.
:param records: qwen recording records (see
:func:`qwen_session_records_from_session_items`).
:param timestamp: ISO stamp for the project ``meta.json``; defaults to now.
:returns: The written ``chats/<id>.jsonl`` recording path.
:raises RuntimeError: If the recording cannot be written.
"""
recording = qwen_session_recording_path(qwen_session_id, workspace)
chats_dir = recording.parent
chats_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
try:
hostname = socket.gethostname() or "omnigent"
except OSError:
hostname = "omnigent"
# Sidecars first so the gate file (``.jsonl``) lands last: a sidecar failure
# then leaves no ``.jsonl`` and the resume gate cleanly picks a fresh launch.
meta_path = chats_dir.parent / "meta.json"
if not meta_path.exists():
stamp = timestamp or _qwen_iso_now()
_atomic_write_text(
meta_path, json.dumps({"version": 1, "createdAt": stamp, "updatedAt": stamp})
)
_atomic_write_text(
chats_dir / f"{qwen_session_id}.runtime.json",
json.dumps(
{
"schema_version": 1,
"pid": os.getpid(),
"session_id": qwen_session_id,
"work_dir": os.path.realpath(str(workspace)),
"hostname": hostname,
"started_at": time.time(),
"qwen_version": _QWEN_SYNTH_VERSION,
}
),
)
_atomic_write_text(
recording, "".join(json.dumps(r, separators=(",", ":")) + "\n" for r in records)
)
return recording
def _atomic_write_text(target: Path, text: str) -> None:
"""Atomically write *text* to *target* (temp file + ``os.replace``).
:param target: Destination path.
:param text: Full file contents to write.
:raises RuntimeError: If the file cannot be written; the temp is cleaned up.
"""
tmp = target.with_suffix(target.suffix + ".tmp")
try:
tmp.write_text(text, encoding="utf-8")
os.replace(tmp, target)
except OSError as exc:
with contextlib.suppress(FileNotFoundError):
tmp.unlink()
raise RuntimeError(f"Failed to write {target}: {exc}") from exc
def input_file_path(bridge_dir: Path) -> Path:
"""Return the ``--input-file`` path qwen watches for JSONL commands."""
return bridge_dir / _INPUT_FILE
def events_file_path(bridge_dir: Path) -> Path:
"""Return the ``--json-file`` path qwen writes structured events to."""
return bridge_dir / _EVENTS_FILE
def _ensure_dir(path: Path) -> None:
"""Create *path* (and parents) with owner-only permissions."""
path.mkdir(parents=True, exist_ok=True)
with contextlib.suppress(OSError):
os.chmod(path, 0o700)
def _ensure_secure_bridge_dir(bridge_dir: Path) -> None:
"""Create/validate *bridge_dir* as an owner-only chain before writing secrets.
``_ensure_dir`` only ``mkdir(parents=True, exist_ok=True)`` + a suppressed
``chmod`` on the leaf: it trusts pre-existing ancestors, so on a shared host
an attacker could pre-create ``$TMPDIR/omnigent-<uid>`` (or a deeper ancestor)
as a symlink / world-writable dir and redirect the bridge tree. That tree now
holds ``bridge.json`` — a bearer token for the relay's localhost control
endpoint — so its directory must be hardened. Delegate to the same
``_ensure_secure_dir`` the shared relay (``start_tool_relay``) already applies
to token-bearing trees; it rejects symlinked / non-owned / group-or-other
accessible ancestors (the qwen-native root is in its allowlist). Lazy import
avoids a cycle (``claude_native_bridge`` resolves qwen's ``bridge_root`` lazily
in turn).
:raises RuntimeError: If any ancestor fails owner-only validation.
"""
from omnigent.claude_native_bridge import _ensure_secure_dir
_ensure_secure_dir(bridge_dir)
def prepare_bridge_files(bridge_dir: Path) -> None:
"""Create the bridge dir and a fresh, empty input file before launch.
qwen ``watchFile``\\s the ``--input-file`` and reads from a byte offset, so
the path must exist when the TUI starts. We truncate it (and the events file)
so a relaunched terminal can't replay a prior process's queued commands.
"""
_ensure_dir(bridge_dir)
# Truncate both so a re-created terminal starts from a clean slate.
input_file_path(bridge_dir).write_text("", encoding="utf-8")
events_file_path(bridge_dir).write_text("", encoding="utf-8")
def build_qwen_native_spawn_env(session_id: str) -> dict[str, str]:
"""Build the ``HARNESS_QWEN_NATIVE_*`` env the harness executor reads.
The executor only needs the bridge dir (to locate the input file it appends
to). qwen's model / auth / dual-output flags are set by the runner when it
launches the TUI (``_auto_create_qwen_terminal``), not here.
:param session_id: The Omnigent session id (keys the bridge dir).
:returns: Env-var overrides for the harness spawn.
"""
bridge_dir = bridge_dir_for_session_id(session_id)
_ensure_dir(bridge_dir)
return {BRIDGE_DIR_ENV_VAR: str(bridge_dir)}
# ---------------------------------------------------------------------------
# Omnigent MCP server config — expose Omnigent's builtin tools (sys_*,
# load_skill, web_fetch, …) to the qwen TUI so it can call them and ``/mcp``
# lists them. Reuses the shared stdio relay implemented in
# ``omnigent.claude_native_bridge serve-mcp`` (same server cursor-/claude-/
# opencode-native point at); only the *registration* surface differs per CLI.
# ---------------------------------------------------------------------------
def write_mcp_bridge_config(bridge_dir: Path) -> None:
"""Write the token config the shared Omnigent MCP relay reads at startup.
The ``serve-mcp`` relay (spawned by qwen) reads ``bridge.json`` for a bearer
token and exits if it's missing, so this must exist *before* qwen launches.
``bridge.json`` only ever holds ``{token}``; the live tool surface is
advertised separately via the ``tool_relay.json`` that the runner's comment
relay (``ensure_comment_relay`` → ``_ensure_comment_relay_started``) writes
into this same bridge dir when it starts. Mirrors
:func:`omnigent.cursor_native_bridge.write_mcp_bridge_config`.
:raises RuntimeError: If the bridge dir fails owner-only validation
(:func:`_ensure_secure_bridge_dir`) — the token is not written.
"""
_ensure_secure_bridge_dir(bridge_dir)
config_path = bridge_dir / _BRIDGE_CONFIG_FILE
if config_path.exists():
return
payload = {"token": secrets.token_urlsafe(32)}
tmp = bridge_dir / (_BRIDGE_CONFIG_FILE + ".tmp")
tmp.write_text(json.dumps(payload, sort_keys=True) + "\n", encoding="utf-8")
os.replace(tmp, config_path)
def build_mcp_server_entry(
bridge_dir: Path,
*,
python_executable: str | None = None,
) -> dict[str, Any]:
"""Build qwen's ``mcpServers.omnigent`` entry for the Omnigent relay.
``trust: true`` auto-approves the qwen-side MCP tool gate so the TUI doesn't
add a second in-terminal prompt: Omnigent already gates these calls through
its own policy/elicitation engine (surfaced as web cards by the approval
mirror), so qwen's prompt would only be a hidden duplicate. Same rationale
as cursor's ``autoApprove`` (see ``cursor_native_bridge.build_mcp_config``).
"""
python = python_executable or sys.executable
return {
"command": python,
"args": [
"-I",
"-m",
"omnigent.claude_native_bridge",
"serve-mcp",
"--bridge-dir",
str(bridge_dir),
],
"env": {
"PYTHONUNBUFFERED": "1",
"TMPDIR": os.environ.get("TMPDIR", "/tmp"),
},
"trust": True,
}
def mcp_config_path(bridge_dir: Path) -> Path:
"""Return the per-session ``--mcp-config`` file path inside the bridge dir."""
return bridge_dir / _MCP_CONFIG_FILE
def write_mcp_config(
bridge_dir: Path,
*,
python_executable: str | None = None,
) -> Path:
"""Write the per-session Omnigent MCP config for qwen's ``--mcp-config`` flag.
Writes ``{"mcpServers": {"omnigent": ...}}`` to a file *inside the bridge dir*
(never the workspace) and the relay token (:func:`write_mcp_bridge_config`).
The runner passes the returned path to qwen via ``--mcp-config <path>``. Unlike
a project ``.mcp.json`` / ``.qwen/settings.json``, a CLI-provided MCP server:
- drops no file in the user's (often git) workspace — nothing to accidentally
commit, nothing left behind pointing at a dead bridge dir;
- is per-session by construction (the file and its ``--bridge-dir`` live in
this session's bridge dir), so concurrent same-workspace sessions can't
collide on a shared file;
- is **not** gated behind qwen's "Untrusted MCP server" prompt (CLI servers
carry no project/workspace scope), so no pre-approval step is needed.
The claude-native ``--mcp-config`` model (it writes no workspace file either).
:returns: Path to the written ``--mcp-config`` file.
"""
write_mcp_bridge_config(bridge_dir)
path = mcp_config_path(bridge_dir)
payload = {
"mcpServers": {
_MCP_SERVER_NAME: build_mcp_server_entry(
bridge_dir, python_executable=python_executable
)
}
}
tmp = path.with_name(f"{_MCP_CONFIG_FILE}.{secrets.token_hex(8)}.tmp")
tmp.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
os.replace(tmp, path)
return path
def _append_command(bridge_dir: Path, command: dict[str, Any]) -> None:
"""Append one JSONL command line to the input file qwen watches.
A single ``write`` of one ``\\n``-terminated line is atomic enough for qwen's
incremental ``readNewLines`` reader (it splits on newlines), so concurrent
appends from ``run_turn`` and a confirmation response don't interleave.
:raises RuntimeError: If the input file can't be written.
"""
line = json.dumps(command, ensure_ascii=False) + "\n"
try:
_ensure_dir(bridge_dir)
with open(input_file_path(bridge_dir), "a", encoding="utf-8") as fh:
fh.write(line)
fh.flush()
except OSError as exc:
raise RuntimeError(f"qwen-native could not write input command: {exc}") from exc
def wait_for_ready(
bridge_dir: Path,
*,
timeout_s: float = 30.0,
poll_interval_s: float = _POLL_INTERVAL_S,
) -> bool:
"""Block until the qwen TUI has booted its dual-output stream.
qwen's ``RemoteInputWatcher`` initializes its read offset (``bytesRead``) to
the **current size of the input file** when it starts watching, synchronously
during TUI boot (before the React app renders). If we append a ``submit``
*before* that runs, qwen initializes ``bytesRead`` past our line and never
reads it — the message is silently dropped. The first turn of a freshly
launched session hits exactly this race, since the harness turn fires while
``qwen`` is still starting up (it takes seconds).
qwen emits its first event — a ``{"type":"system","subtype":"session_start"}``
on the ``--json-file`` stream — only *after* the watcher's constructor (and
thus ``startWatching``) has run. So the appearance of a ``system`` event in
the events file is a safe "the watcher is active, ``bytesRead`` was taken on
the still-empty input file" signal: appending after it is reliably detected.
:param bridge_dir: The qwen-native bridge dir holding the events file.
:param timeout_s: Max seconds to wait for the boot signal.
:param poll_interval_s: Seconds between polls of the events file.
:returns: ``True`` once the boot signal is seen; ``False`` on timeout (the
caller submits anyway — best effort beats hanging the turn).
"""
events_file = events_file_path(bridge_dir)
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
if _events_file_has_system_event(events_file):
return True
time.sleep(poll_interval_s)
return False
def _events_file_has_system_event(events_file: Path) -> bool:
"""Return whether the NDJSON events file contains a parsed ``system`` event.
Parses line by line and checks ``event["type"] == "system"`` rather than a
raw substring scan: a substring like ``"type":"system"`` could appear inside
another event's payload (latching ready early and re-opening the boot race),
and the first ``system`` event isn't guaranteed to sit within a fixed byte
window. The boot ``session_start`` is qwen's first emitted line, so this
returns on the first line in practice.
"""
try:
with open(events_file, encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line:
continue
try:
event = json.loads(line)
except ValueError:
continue # tolerate a partial/garbled line mid-write
if isinstance(event, dict) and event.get("type") == "system":
return True
except OSError:
return False
return False
def submit_user_message(bridge_dir: Path, *, content: str) -> None:
"""Deliver a web-UI user message into the qwen TUI via the input file.
Appends ``{"type":"submit","text":content}``; qwen's ``RemoteInputWatcher``
routes it through ``submitQuery`` (the keyboard's submit path), so the message
renders in the TUI transcript exactly like typed input.
:param bridge_dir: The qwen-native bridge dir holding the input file.
:param content: User text (non-empty).
:raises RuntimeError: If *content* is empty or the input file can't be written.
"""
if not content:
raise RuntimeError("qwen-native submit requires non-empty content")
_append_command(bridge_dir, {"type": "submit", "text": content})
def submit_confirmation(bridge_dir: Path, *, request_id: str, allowed: bool) -> None:
"""Answer a qwen ``can_use_tool`` control request via the input file.
:param bridge_dir: The qwen-native bridge dir holding the input file.
:param request_id: The ``request_id`` from the ``control_request`` event.
:param allowed: Whether the tool call is permitted.
:raises RuntimeError: If the input file can't be written.
"""
_append_command(
bridge_dir,
{"type": "confirmation_response", "request_id": request_id, "allowed": allowed},
)
# ---------------------------------------------------------------------------
# tmux target (display pane) — used only for interrupt / hard-stop.
# ---------------------------------------------------------------------------
def write_tmux_target(
bridge_dir: Path,
*,
socket_path: Path,
tmux_target: str,
pid: int | None = None,
) -> None:
"""Advertise the tmux socket + target for the running qwen terminal."""
_ensure_dir(bridge_dir)
payload: dict[str, Any] = {
"socket_path": str(socket_path),
"tmux_target": tmux_target,
"updated_at": time.time(),
}
if pid is not None:
payload["pid"] = pid
tmp = bridge_dir / (_TMUX_FILE + ".tmp")
tmp.write_text(json.dumps(payload), encoding="utf-8")
os.replace(tmp, bridge_dir / _TMUX_FILE)
def read_tmux_info(bridge_dir: Path) -> dict[str, str] | None:
"""Return ``{socket_path, tmux_target}`` from ``tmux.json``, or ``None``."""
try:
raw = (bridge_dir / _TMUX_FILE).read_text(encoding="utf-8")
except OSError:
return None
try:
data = json.loads(raw)
except ValueError:
return None
socket_path = data.get("socket_path")
tmux_target = data.get("tmux_target")
if (
isinstance(socket_path, str)
and socket_path
and isinstance(tmux_target, str)
and tmux_target
):
return {"socket_path": socket_path, "tmux_target": tmux_target}
return None
def _wait_for_tmux_info(bridge_dir: Path, *, timeout_s: float) -> dict[str, str]:
"""Block until ``tmux.json`` is advertised, or raise on timeout."""
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
info = read_tmux_info(bridge_dir)
if info is not None:
return info
time.sleep(_POLL_INTERVAL_S)
raise RuntimeError(f"qwen-native tmux target was not advertised within {timeout_s:.0f}s")
def _run_tmux(socket_path: str, *args: str) -> None:
"""Invoke ``tmux -S <socket> <args...>`` and raise on failure."""
try:
proc = subprocess.run(
["tmux", "-S", socket_path, *args],
check=False,
capture_output=True,
text=True,
timeout=_TMUX_SEND_TIMEOUT_S,
)
except subprocess.TimeoutExpired as exc:
raise RuntimeError(f"tmux command timed out after {_TMUX_SEND_TIMEOUT_S}s") from exc
if proc.returncode != 0:
detail = proc.stderr.strip() or proc.stdout.strip() or "<no output>"
raise RuntimeError(f"tmux command failed (rc={proc.returncode}): {detail}")
def inject_interrupt(bridge_dir: Path, *, timeout_s: float = _TMUX_READY_TIMEOUT_S) -> None:
"""Cancel the in-flight qwen turn by sending ``Escape`` to the pane.
qwen's input-file watcher accepts only ``submit`` / ``confirmation_response``,
so the web UI's Stop button drives interrupt through the display pane — the
analog of :func:`submit_user_message` for cancellation. The harness
``run_turn`` returns right after appending the submit line, so the runner's
in-process cancel floor can't reach the turn.
:raises RuntimeError: If the tmux target is not advertised or send-keys fails.
"""
info = _wait_for_tmux_info(bridge_dir, timeout_s=timeout_s)
# No ``-l``: tmux must interpret ``Escape`` as a key name.
_run_tmux(info["socket_path"], "send-keys", "-t", info["tmux_target"], "Escape")
def kill_session(bridge_dir: Path, *, timeout_s: float = _TMUX_READY_TIMEOUT_S) -> None:
"""Hard-stop the qwen session by killing its tmux session.
Terminates ``qwen`` and the pane outright — the analog of the user manually
exiting the attached TUI, for the web UI's "Stop session" affordance.
:raises RuntimeError: If the tmux target is not advertised or kill-session fails.
"""
info = _wait_for_tmux_info(bridge_dir, timeout_s=timeout_s)
_run_tmux(info["socket_path"], "kill-session", "-t", info["tmux_target"])