#!/usr/bin/env python3 """Python Popen scheduling for dsh-cmd-starter. Uses `dsh --profile headless --json` so every run streams the official newline-delimited run events (`session` / `status` / `text` / `tool_call` / `tool_result` / `final`). The first `session` event carries the session id that a later `--resume` / `-c` / `--name` reuses; the terminal `final` event carries the same answer the default (non-JSON) mode prints. No Python SDK needed. The `RunResult` shape mirrors `panda-pipline`'s `AgentRunResult` (`text` / `session_id` / `returncode` / `error`), so the two can share a driver contract. """ import json import subprocess from dataclasses import dataclass from typing import Optional @dataclass(frozen=True) class RunResult: """Mirror of panda-pipline.agents._result.AgentRunResult.""" text: str session_id: Optional[str] returncode: int error: Optional[str] = None class Dsh: def __init__(self, profile: str = "headless"): self.profile = profile self.last_session_id: Optional[str] = None def run( self, task: str, append_prompt: Optional[list[str]] = None, resume: Optional[str] = None, continue_latest: bool = False, name: Optional[str] = None, cwd: Optional[str] = None, provider: Optional[str] = None, model: Optional[str] = None, max_tokens: Optional[int] = None, effort: Optional[str] = None, timeout: int = 900, ) -> RunResult: args = ["dsh", "--profile", self.profile, "--json"] for prompt in append_prompt or []: args += ["--append-prompt", prompt] if resume is not None: args += ["--resume", resume] if continue_latest: args += ["--continue"] if name is not None: args += ["--name", name] if provider is not None: args += ["--provider", provider] if model is not None: args += ["--model", model] if max_tokens is not None: args += ["--max-tokens", str(max_tokens)] if effort is not None: args += ["--effort", effort] args.append(task) try: proc = subprocess.run(args, capture_output=True, text=True, cwd=cwd, timeout=timeout) except Exception as error: return RunResult("", self.last_session_id, -1, str(error)) session_id, text, stream_error = _parse_stream(proc.stdout) session_id = session_id or self.last_session_id error = None if proc.returncode == 0 else ( stream_error or proc.stderr.strip() or f"dsh exited with code {proc.returncode}" ) self.last_session_id = session_id return RunResult(text, session_id, proc.returncode, error) def _parse_stream(stdout: str) -> tuple[Optional[str], str, Optional[str]]: """Read the NDJSON run stream: session id, final answer, and a turn error.""" session_id: Optional[str] = None text = "" error: Optional[str] = None for line in stdout.splitlines(): line = line.strip() if not line: continue try: data = json.loads(line) except json.JSONDecodeError: continue if not isinstance(data, dict): continue kind = data.get("type") if kind == "session": session_id = data.get("sessionId") or session_id elif kind == "final": text = data.get("text") or text elif kind == "status" and data.get("phase") == "turn_end": reason = data.get("reason") or {} if isinstance(reason, dict) and reason.get("kind") == "error": failure = reason.get("error") or {} error = f"{failure.get('code', 'UNKNOWN')}: {failure.get('message', '')}" return session_id, text, error if __name__ == "__main__": d = Dsh() # 1. 首次运行:新建会话,拿到 session id r1 = d.run("分析 src/ 目录结构并总结", append_prompt=["be concise"]) print("first sessionId:", r1.session_id) print("response:", r1.text[:200]) # 2. 续上同一个会话 r2 = d.run("继续,把总结写成 Markdown 文档", resume=d.last_session_id) print("resumed sessionId:", r2.session_id) # 3. 或直接续最近的会话(-c) r3 = d.run("再压缩到 3 条要点", continue_latest=True) print("continued:", r3.returncode == 0) # 4. 命名会话,之后按名字恢复(不记 session id) r4 = d.run("审查这个 PR", name="pr-review") print("named sessionId:", r4.session_id) r5 = d.run("继续审查,重点看并发安全", resume="pr-review") print("resumed by name:", r5.session_id)