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:
2026-05-10 21:14:43 -04:00
parent 228fe8d1ac
commit a29793032d
35 changed files with 2067 additions and 37 deletions

View File

View 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())

View 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,
)

View 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())

View File