Document current coding-workflow stack state
Snapshot of where opencode + Qwen3-Coder + MCPs + Kimi-Linear + voice + Phoenix tracing land today, plus in-flight (oc-tree, kimi-linear context ramp) and next (ComfyUI) items with pointers to per-project NEXT_STEPS.md guides.
This commit is contained in:
0
oc-tree/src/oc_tree/__init__.py
Normal file
0
oc-tree/src/oc_tree/__init__.py
Normal file
20
oc-tree/src/oc_tree/__main__.py
Normal file
20
oc-tree/src/oc_tree/__main__.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""Entry point. M0: stub that points users at the probe.
|
||||
|
||||
The textual app lands in M1+. Until then, this command exists so the
|
||||
script registration in pyproject.toml resolves.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print(
|
||||
"oc-tree TUI ships in M1.\n"
|
||||
"For now, run the schema probe:\n"
|
||||
" uv run oc-tree-probe\n"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
93
oc-tree/src/oc_tree/client.py
Normal file
93
oc-tree/src/oc_tree/client.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""Thin async client for opencode's HTTP + SSE API.
|
||||
|
||||
Talks to `opencode serve` (default 127.0.0.1:4096). Auth is off unless
|
||||
OPENCODE_SERVER_PASSWORD is set, matching upstream defaults.
|
||||
|
||||
The single SSE endpoint is `GET /event`; per-session streams don't exist
|
||||
(sst/opencode#7451), so callers filter by sessionID client-side.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from httpx_sse import aconnect_sse
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Event:
|
||||
type: str
|
||||
properties: dict[str, Any]
|
||||
raw: dict[str, Any]
|
||||
|
||||
|
||||
def _auth_header() -> dict[str, str]:
|
||||
pw = os.environ.get("OPENCODE_SERVER_PASSWORD", "")
|
||||
if not pw:
|
||||
return {}
|
||||
user = os.environ.get("OPENCODE_SERVER_USERNAME", "opencode")
|
||||
token = base64.b64encode(f"{user}:{pw}".encode()).decode()
|
||||
return {"Authorization": f"Basic {token}"}
|
||||
|
||||
|
||||
class OpenCodeClient:
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str | None = None,
|
||||
*,
|
||||
timeout: float = 30.0,
|
||||
) -> None:
|
||||
self.base_url = (
|
||||
base_url
|
||||
or os.environ.get("OPENCODE_URL")
|
||||
or "http://127.0.0.1:4096"
|
||||
).rstrip("/")
|
||||
# SSE needs no read timeout; REST calls cap at `timeout`.
|
||||
self._sse_timeout = httpx.Timeout(timeout, read=None)
|
||||
self._rest_timeout = httpx.Timeout(timeout)
|
||||
self._headers = _auth_header()
|
||||
|
||||
async def list_sessions(
|
||||
self, *, scope: str = "project", limit: int = 50
|
||||
) -> list[dict[str, Any]]:
|
||||
async with httpx.AsyncClient(
|
||||
base_url=self.base_url,
|
||||
headers=self._headers,
|
||||
timeout=self._rest_timeout,
|
||||
) as c:
|
||||
r = await c.get("/session", params={"scope": scope, "limit": limit})
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
async def get_session_messages(self, session_id: str) -> list[dict[str, Any]]:
|
||||
async with httpx.AsyncClient(
|
||||
base_url=self.base_url,
|
||||
headers=self._headers,
|
||||
timeout=self._rest_timeout,
|
||||
) as c:
|
||||
r = await c.get(f"/session/{session_id}/message")
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
async def stream_events(self) -> AsyncIterator[Event]:
|
||||
"""Yield events from /event. Caller handles reconnect."""
|
||||
async with httpx.AsyncClient(
|
||||
base_url=self.base_url,
|
||||
headers=self._headers,
|
||||
timeout=self._sse_timeout,
|
||||
) as c:
|
||||
async with aconnect_sse(c, "GET", "/event") as src:
|
||||
async for sse in src.aiter_sse():
|
||||
if not sse.data:
|
||||
continue
|
||||
payload = sse.json()
|
||||
yield Event(
|
||||
type=payload.get("type", ""),
|
||||
properties=payload.get("properties", {}) or {},
|
||||
raw=payload,
|
||||
)
|
||||
100
oc-tree/src/oc_tree/probe.py
Normal file
100
oc-tree/src/oc_tree/probe.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""Schema probe: dump raw /event frames to JSONL for inspection.
|
||||
|
||||
Used in M0 to verify three open questions before building the UI:
|
||||
1. Does session.created.info.parentID get populated for Task subagents?
|
||||
(sst/opencode#6573 — may be broken over `opencode serve`.)
|
||||
2. Does message.part.updated carry the full part or a delta?
|
||||
(sst/opencode#11424 — frames sometimes replay full state.)
|
||||
3. What permission.* event names actually fire? (Undocumented.)
|
||||
|
||||
Run, drive opencode through a session that includes a Task-tool
|
||||
subagent + a permission prompt + at least one tool call, then grep the
|
||||
output JSONL for `parentID`, `permission`, and successive part frames.
|
||||
|
||||
Usage:
|
||||
uv run oc-tree-probe # writes /tmp/oc-tree-probe.jsonl
|
||||
uv run oc-tree-probe --out file.jsonl
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import signal
|
||||
import sys
|
||||
from collections import Counter
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from .client import OpenCodeClient
|
||||
|
||||
|
||||
async def _run(out_path: Path) -> int:
|
||||
client = OpenCodeClient()
|
||||
counts: Counter[str] = Counter()
|
||||
print(f"oc-tree probe → {out_path}")
|
||||
print(f" base_url={client.base_url}")
|
||||
print(" drive opencode now; ctrl-c to stop and print summary\n")
|
||||
|
||||
stop = asyncio.Event()
|
||||
loop = asyncio.get_running_loop()
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
loop.add_signal_handler(sig, stop.set)
|
||||
|
||||
with out_path.open("a", encoding="utf-8") as f:
|
||||
try:
|
||||
stream = client.stream_events()
|
||||
stream_task = asyncio.create_task(_consume(stream, f, counts))
|
||||
stop_task = asyncio.create_task(stop.wait())
|
||||
done, pending = await asyncio.wait(
|
||||
{stream_task, stop_task},
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
for t in pending:
|
||||
t.cancel()
|
||||
for t in done:
|
||||
exc = t.exception()
|
||||
if exc and not isinstance(exc, asyncio.CancelledError):
|
||||
print(f"\nstream error: {exc!r}", file=sys.stderr)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
print("\n--- event counts ---")
|
||||
for t, n in counts.most_common():
|
||||
print(f" {n:>5} {t}")
|
||||
return 0
|
||||
|
||||
|
||||
async def _consume(stream, f, counts: Counter[str]) -> None:
|
||||
async for event in stream:
|
||||
counts[event.type] += 1
|
||||
line = json.dumps(
|
||||
{
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
"type": event.type,
|
||||
"raw": event.raw,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
f.write(line + "\n")
|
||||
f.flush()
|
||||
# Live tick so the operator knows it's working.
|
||||
sys.stdout.write(f"\r{sum(counts.values()):>6} events last={event.type:<40}")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="opencode SSE schema probe")
|
||||
ap.add_argument(
|
||||
"--out",
|
||||
type=Path,
|
||||
default=Path("/tmp/oc-tree-probe.jsonl"),
|
||||
help="output JSONL file (appended)",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
return asyncio.run(_run(args.out))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
0
oc-tree/src/oc_tree/widgets/__init__.py
Normal file
0
oc-tree/src/oc_tree/widgets/__init__.py
Normal file
Reference in New Issue
Block a user