Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 26 additions & 37 deletions SKILL.md

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions orchestrator/run_phase.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ def _parse_binding(raw: str) -> tuple[str, str]:
def _detect_transcript_cli(selected: str) -> str:
if selected != "auto":
return selected
if os.environ.get("PI_CODING_AGENT") == "true":
return "pi-cli"
if os.environ.get("CODEX_THREAD_ID") or os.environ.get("CODEX_HOME"):
return "codex-cli"
if os.environ.get("CLAUDECODE"):
Expand Down Expand Up @@ -286,7 +288,7 @@ def _add_prepare_arguments(parser: argparse.ArgumentParser) -> None:
)
parser.add_argument(
"--transcript-cli",
choices=["auto", "codex-cli", "claude-code", "kimi-cli", "opencode"],
choices=["auto", "codex-cli", "claude-code", "kimi-cli", "opencode", "pi-cli"],
default="auto",
help="Transcript provider to use for transcript placeholders.",
)
Expand Down Expand Up @@ -335,7 +337,7 @@ def build_parser() -> argparse.ArgumentParser:
_add_prepare_arguments(run_parser)
run_parser.add_argument(
"--backend",
choices=["auto", "host", "codex", "claude", "kimi", "opencode"],
choices=["auto", "host", "codex", "claude", "kimi", "opencode", "pi"],
default="auto",
help="Subagent backend selection policy.",
)
Expand Down
144 changes: 135 additions & 9 deletions orchestrator/subagent_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"claude": "TRYCYCLE_CLAUDE_MODEL",
"kimi": "TRYCYCLE_KIMI_MODEL",
"opencode": "TRYCYCLE_OPENCODE_MODEL",
"pi": "TRYCYCLE_PI_MODEL",
}


Expand Down Expand Up @@ -259,6 +260,8 @@ def _probe_opencode(binary: str) -> dict[str, Any]:


def _detect_host_backend() -> str | None:
if os.environ.get("PI_CODING_AGENT") == "true":
return "pi"
if os.environ.get("CODEX_THREAD_ID") or os.environ.get("CODEX_HOME"):
return "codex"
if os.environ.get("CLAUDECODE"):
Expand All @@ -270,13 +273,15 @@ def _detect_host_backend() -> str | None:

def _detect_backend_preferences() -> list[str]:
host_backend = _detect_host_backend()
if host_backend == "pi":
return ["pi", "codex", "claude", "kimi", "opencode"]
if host_backend == "codex":
return ["codex", "claude", "kimi", "opencode"]
return ["codex", "claude", "kimi", "opencode", "pi"]
if host_backend == "claude":
return ["claude", "codex", "kimi", "opencode"]
return ["claude", "codex", "kimi", "opencode", "pi"]
if host_backend == "opencode":
return ["opencode", "codex", "claude", "kimi"]
return ["codex", "claude", "kimi", "opencode"]
return ["opencode", "codex", "claude", "kimi", "pi"]
return ["codex", "claude", "kimi", "opencode", "pi"]


def _probe_backends() -> dict[str, Any]:
Expand All @@ -285,6 +290,7 @@ def _probe_backends() -> dict[str, Any]:
"claude": _probe_claude("claude"),
"kimi": _probe_kimi("kimi"),
"opencode": _probe_opencode("opencode"),
"pi": _probe_pi("pi"),
}

preferred_order = _detect_backend_preferences()
Expand Down Expand Up @@ -970,6 +976,100 @@ def _extract_opencode_reply_from_db(session_id: str, db_path: Path | None = None
return ""


def _probe_pi(binary: str) -> dict[str, Any]:
path = _resolve_binary(binary)
if path is None:
return {
"available": False,
"binary": binary,
"reason": "binary not found on PATH",
}

ok, output = _run_probe([path, "--help"])
if not ok:
return {
"available": False,
"binary": path,
"reason": output,
}

required_tokens = ["--print", "--session", "--session-dir", "--no-skills", "--model"]
missing = [token for token in required_tokens if token not in output]
if missing:
return {
"available": False,
"binary": path,
"reason": f"missing required help tokens: {', '.join(missing)}",
}

return {
"available": True,
"binary": path,
"supports_resume": True,
}


def _pi_command(
*,
binary: str,
artifacts_dir: Path,
effort: str | None,
model: str | None,
) -> list[str]:
command = [
binary,
"-p",
"--session-dir",
str(artifacts_dir),
"--no-skills",
"--no-extensions",
"--no-prompt-templates",
"--no-context-files",
]
if model:
command.extend(["--model", model])
if effort:
command.extend(["--thinking", effort])
return command


def _pi_resume_command(
*,
binary: str,
session_id: str,
effort: str | None,
model: str | None,
) -> list[str]:
command = [
binary,
"-p",
"--session",
session_id,
"--no-skills",
"--no-extensions",
"--no-prompt-templates",
"--no-context-files",
]
if model:
command.extend(["--model", model])
if effort:
command.extend(["--thinking", effort])
return command


def _extract_pi_session_id(artifacts_dir: Path) -> str | None:
"""Extract session ID from the first JSONL file found in artifacts_dir."""
for jsonl_path in sorted(artifacts_dir.glob("*.jsonl")):
try:
first_line = jsonl_path.read_text(encoding="utf-8").splitlines()[0]
record = json.loads(first_line)
if record.get("type") == "session":
return record.get("id")
except (OSError, json.JSONDecodeError, IndexError):
continue
return None


def _opencode_command(
*,
binary: str,
Expand Down Expand Up @@ -1074,6 +1174,15 @@ def _run_backend(
model=model,
)
cwd = workdir
elif backend == "pi":
command = _pi_command(
binary=binary,
artifacts_dir=reply_path.parent,
effort=effort,
model=model,
)
cwd = workdir
session_id = None
else:
raise ValueError(f"unsupported backend: {backend}")

Expand Down Expand Up @@ -1160,6 +1269,9 @@ def _run_backend(
if not reply_text.strip() and session_id and result.returncode == 0:
reply_text = _extract_opencode_reply_from_db(session_id)
reply_path.write_text(reply_text, encoding="utf-8")
elif backend == "pi":
reply_text = result.stdout or ""
reply_path.write_text(reply_text, encoding="utf-8")
elif backend in {"claude", "kimi"}:
reply_text = result.stdout or ""
reply_path.write_text(reply_text, encoding="utf-8")
Expand All @@ -1185,6 +1297,9 @@ def _run_backend(
started_at=session_lookup_started_at,
)

if backend == "pi" and result.returncode == 0 and session_id is None:
session_id = _extract_pi_session_id(reply_path.parent)

return {
"command": command,
"exit_code": result.returncode,
Expand Down Expand Up @@ -1249,6 +1364,14 @@ def _resume_backend(
model=model,
)
cwd = workdir
elif backend == "pi":
command = _pi_resume_command(
binary=binary,
session_id=session_id,
effort=effort,
model=model,
)
cwd = workdir
else:
raise ValueError(f"unsupported backend: {backend}")

Expand Down Expand Up @@ -1336,6 +1459,9 @@ def _resume_backend(
if not reply_text.strip() and session_id and result.returncode == 0:
reply_text = _extract_opencode_reply_from_db(session_id)
reply_path.write_text(reply_text, encoding="utf-8")
elif backend == "pi":
reply_text = result.stdout or ""
reply_path.write_text(reply_text, encoding="utf-8")
elif backend in {"claude", "kimi"}:
reply_text = result.stdout or ""
reply_path.write_text(reply_text, encoding="utf-8")
Expand Down Expand Up @@ -1736,19 +1862,19 @@ def _command_resume(args: argparse.Namespace) -> int:

def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Safe fallback runner for trycycle subagent dispatch via Codex, Claude, Kimi, or OpenCode.",
description="Safe fallback runner for trycycle subagent dispatch via Codex, Claude, Kimi, OpenCode, or Pi.",
)
subparsers = parser.add_subparsers(dest="command", required=True)

probe_parser = subparsers.add_parser(
"probe",
help="Detect supported Codex, Claude, Kimi, and OpenCode backends.",
help="Detect supported Codex, Claude, Kimi, OpenCode, and Pi backends.",
)
probe_parser.set_defaults(func=_command_probe)

run_parser = subparsers.add_parser(
"run",
help="Run a subagent prompt through Codex, Claude, Kimi, or OpenCode without shell quoting.",
help="Run a subagent prompt through Codex, Claude, Kimi, OpenCode, or Pi without shell quoting.",
)
run_parser.add_argument(
"--phase",
Expand All @@ -1771,7 +1897,7 @@ def build_parser() -> argparse.ArgumentParser:
)
run_parser.add_argument(
"--backend",
choices=["auto", "host", "codex", "claude", "kimi", "opencode"],
choices=["auto", "host", "codex", "claude", "kimi", "opencode", "pi"],
default="auto",
help="Backend selection policy. Use 'host' to stay on the parent backend.",
)
Expand Down Expand Up @@ -1830,7 +1956,7 @@ def build_parser() -> argparse.ArgumentParser:
)
resume_parser.add_argument(
"--backend",
choices=["auto", "host", "codex", "claude", "kimi", "opencode"],
choices=["auto", "host", "codex", "claude", "kimi", "opencode", "pi"],
default="auto",
help="Backend selection policy. Use 'host' to stay on the parent backend.",
)
Expand Down
2 changes: 2 additions & 0 deletions orchestrator/user-request-transcript/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import codex_cli
import kimi_cli
import opencode_cli
import pi_cli
from common import TranscriptError, choose_most_recent_match, render_transcript


Expand All @@ -16,6 +17,7 @@
"codex-cli": codex_cli,
"kimi-cli": kimi_cli,
"opencode": opencode_cli,
"pi-cli": pi_cli,
}


Expand Down
101 changes: 101 additions & 0 deletions orchestrator/user-request-transcript/pi_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
from __future__ import annotations

from pathlib import Path

from common import TranscriptTurn, iter_jsonl_records, wait_for_matches


DEFAULT_PI_SESSIONS_ROOT = Path.home() / ".pi" / "agent" / "sessions"


def _encode_cwd(cwd: str) -> str:
"""Encode a cwd path into a Pi session directory name.

Pi encodes: strip leading '/', replace '/' with '-', replace ':' with '-',
wrap in '--'.
"""
encoded = cwd.lstrip("/")
encoded = encoded.replace("/", "-")
encoded = encoded.replace(":", "-")
return f"--{encoded}--"


def _resolve_sessions_root(search_root: Path | None = None) -> Path:
if search_root is not None:
return search_root
return DEFAULT_PI_SESSIONS_ROOT


def find_matching_transcripts(
*,
canary: str,
timeout_ms: int,
poll_ms: int,
search_root: Path | None = None,
) -> list[Path]:
root = _resolve_sessions_root(search_root)
if not root.exists():
from common import TranscriptError

raise TranscriptError(f"Pi sessions root does not exist: {root}")

return wait_for_matches(
root=root,
canary=canary,
timeout_ms=timeout_ms,
poll_ms=poll_ms,
)


def extract_transcript(path: Path) -> list[TranscriptTurn]:
selected_turns: list[TranscriptTurn] = []
pending_assistant: TranscriptTurn | None = None
saw_user = False

for line_number, record in iter_jsonl_records(path):
record_type = record.get("type")

if record_type != "message":
continue

message = record.get("message", {})
role = message.get("role")
content_blocks = message.get("content", [])

if not isinstance(content_blocks, list):
continue

if role == "user":
user_text = "".join(
block.get("text", "")
for block in content_blocks
if isinstance(block, dict) and block.get("type") == "text"
)
if user_text:
if saw_user and pending_assistant is not None:
selected_turns.append(pending_assistant)
pending_assistant = None
selected_turns.append(
TranscriptTurn(order=line_number, role="user", text=user_text)
)
saw_user = True
continue

if role == "assistant":
# Only include visible text, exclude thinking and toolCall
visible_reply = "".join(
block.get("text", "")
for block in content_blocks
if isinstance(block, dict) and block.get("type") == "text"
)
if visible_reply:
pending_assistant = TranscriptTurn(
order=line_number,
role="assistant",
text=visible_reply,
)

if pending_assistant is not None:
selected_turns.append(pending_assistant)

return selected_turns
Loading