Skip to content
Closed
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

## Unreleased

- Add DeepSeek Harness (`dsh`) and Pi (`pi`) to the coding-tool catalog,
Profile presets, and dispatch discovery. Neither has an audited
non-interactive adapter yet, so `dispatch` can see them but will not
route work. Install the control-plane Skill avatar for Pi when
`~/.pi/agent` is present, and for DeepSeek Harness when `~/.dsh` is
present.

## 0.6.7 - 2026-08-13

- Make bare `dyro update` check, confirm, and install (same path as
Expand Down
2 changes: 1 addition & 1 deletion docs/designs/optional-local-agent-dispatch.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ process-tree 与 pipe 后端。
安装/刷新时:

1. `dyro dispatch backends` 探测本机命令与登录态;当前受审计、可执行的集成为 `codex` 和 `claude`。
2. `cursor-agent`、`opencode`、`grok`、`hermes`、`kimi` 会被展示为“已发现但未集成”,不得被自动或手动路由,直到各自拥有经过审计的非交互协议 adapter。
2. `cursor-agent`、`opencode`、`grok`、`hermes`、`kimi`、`dsh`、`pi` 会被展示为“已发现但未集成”,不得被自动或手动路由,直到各自拥有经过审计的非交互协议 adapter。
3. 当只有一个已认证 Provider 时,`backend: auto` 可以选择它;当有多个时,用户必须使用 `dyro dispatch route add default <provider>` 选择默认路由;一个也没有时 fail-closed 并给出发现结果。
4. `echo` 仅用于显式 `--backend echo --allow-offline-simulation` 的确定性测试,输出的 `execution_kind=offline-simulation`、低置信度和非生产警告不得被上游当作真实模型结论。
5. 渲染 skill 正文只含已准备 Provider、发现但未集成的命令、路由表与上述限制,再分发到各宿主 skills 目录。
Expand Down
2 changes: 1 addition & 1 deletion docs/tool-catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ page.

Current catalog entries include Antigravity CLI, Codex CLI, Codex App, Claude
Code CLI, Claude Code Desktop, Cursor Desktop, Cursor CLI, Grok, OpenCode,
OpenClaw, Hermes, Kimi Code, Qoder CLI, ZCode, and Shell. Antigravity uses the
OpenClaw, Hermes, Kimi Code, DeepSeek Harness, Pi, Qoder CLI, ZCode, and Shell. Antigravity uses the
official `agy` command; Qoder uses `qodercli`; ZCode is opened as a desktop
workspace tool. An entry can be known without having an audited installation
recipe; such entries fail closed with an actionable message.
Expand Down
2 changes: 1 addition & 1 deletion experiments/local_agent_dispatch/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ closed until a Windows process-tree backend is implemented.

See design §4 (five-part contract). `auto` considers only integrated, authenticated
Providers; with several it requires `dyro dispatch route add default <backend>`, and
with none it fails closed. `cursor-agent`, `opencode`, `grok`, `hermes`, and `kimi`
with none it fails closed. `cursor-agent`, `opencode`, `grok`, `hermes`, `kimi`, `dsh`, and `pi`
may be discovered, but cannot run until an audited adapter exists. Backend `echo` is
an explicit offline simulation: task JSON must set `allow_offline_simulation: true`
and callers must not treat its low-confidence result as a Provider conclusion.
Expand Down
2 changes: 2 additions & 0 deletions experiments/local_agent_dispatch/adapters/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
"grok": "grok",
"hermes": "hermes",
"kimi": "kimi",
"dsh": "dsh",
"pi": "pi",
}


Expand Down
8 changes: 6 additions & 2 deletions src/dyro/integrations/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ class HostSpec:
HostSpec("agents", "AGENTS_HOME", ".agents"),
HostSpec("cursor", "CURSOR_HOME", ".cursor"),
HostSpec("grok", "GROK_HOME", ".grok"),
HostSpec("pi", "PI_CODING_AGENT_DIR", ".pi/agent"),
HostSpec("dsh", "DSH_HOME", ".dsh"),
)


Expand Down Expand Up @@ -874,9 +876,11 @@ def plan_integration(
changes = ("无需写入;Integration 已是当前版本",)
elif status.state is IntegrationState.ABSENT:
if not status.avatars:
host_envs = " / ".join(
spec.env_var for spec in HOSTS if spec.env_var
)
changes = (
"无法安装:未检测到宿主目录;需先创建或设置 "
"CODEX_HOME / CLAUDE_HOME / AGENTS_HOME / CURSOR_HOME",
"无法安装:未检测到宿主目录;需先创建或设置 " + host_envs,
"不会创建孤立镜像或分身",
)
else:
Expand Down
34 changes: 34 additions & 0 deletions src/dyro/tooling.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,40 @@ class ToolPreferences:
risk=_NPM_RISK,
),
),
ToolDefinition(
"dsh",
"DeepSeek Harness",
"dsh",
"terminal",
("dsh", "web"),
install=InstallGuide(
"https://github.com/deepseek-ai/deepseek-harness",
_NPM_SCOPE,
("npm", "install", "-g", "@deepseek-ai/dsh@latest"),
prerequisite="npm",
risk=_NPM_RISK,
),
),
ToolDefinition(
"pi",
"Pi",
"pi",
"terminal",
("pi",),
install=InstallGuide(
"https://pi.dev/docs/latest",
_NPM_SCOPE,
(
"npm",
"install",
"-g",
"--ignore-scripts",
"@earendil-works/pi-coding-agent@latest",
),
prerequisite="npm",
risk=_NPM_RISK,
),
),
ToolDefinition(
"qoder",
"Qoder CLI",
Expand Down
44 changes: 42 additions & 2 deletions tests/test_integrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,13 @@ def setUp(self) -> None:
self.dyro_home = self.root / "dyro"
self.fake_home = self.root / "home"
self.fake_home.mkdir()
isolated_hosts = {
spec.env_var: "" for spec in manager.HOSTS if spec.env_var
}
self.environment = patch.dict(
os.environ,
{
**isolated_hosts,
"CODEX_HOME": str(self.codex_home),
"DYRO_HOME": str(self.dyro_home),
"HOME": str(self.fake_home),
Expand Down Expand Up @@ -688,10 +692,23 @@ def test_sync_managed_skill_upgrades_outdated(self) -> None:
assert plan is not None
self.assertEqual(plan.status.state, IntegrationState.CURRENT)

def test_setup_isolates_every_host_env_var(self) -> None:
for spec in manager.HOSTS:
if not spec.env_var:
continue
if spec.env_var == "CODEX_HOME":
self.assertEqual(os.environ.get(spec.env_var), str(self.codex_home))
continue
self.assertFalse(
os.environ.get(spec.env_var, "").strip(),
msg=spec.env_var,
)

def test_plan_surfaces_missing_host_blocker(self) -> None:
with patch.dict(os.environ):
for key in ("CODEX_HOME", "CLAUDE_HOME", "AGENTS_HOME", "CURSOR_HOME"):
os.environ.pop(key, None)
for spec in manager.HOSTS:
if spec.env_var:
os.environ.pop(spec.env_var, None)
plan = install_integration("skill", yes=False, dry_run=True)
self.assertEqual(plan.status.state, IntegrationState.ABSENT)
self.assertFalse(plan.status.avatars)
Expand Down Expand Up @@ -759,5 +776,28 @@ def test_cli_status_dry_run_install_and_confirmation_gate(self) -> None:
self.assertEqual(integration_status("skill").state, IntegrationState.ABSENT)


def test_pi_skill_host_uses_coding_agent_dir(self) -> None:
spec = next(host for host in manager.HOSTS if host.host_id == "pi")
self.assertEqual(spec.env_var, "PI_CODING_AGENT_DIR")
self.assertEqual(spec.default_dirname, ".pi/agent")

pi_home = self.root / "pi-agent"
install_integration("skill", yes=True, host_homes={"pi": pi_home})
avatar = pi_home / "skills" / "dyro-control-plane"
self.assertTrue(avatar.is_symlink() or avatar.is_dir())
self.assertTrue((avatar / "SKILL.md").is_file())

def test_dsh_skill_host_uses_dsh_home(self) -> None:
spec = next(host for host in manager.HOSTS if host.host_id == "dsh")
self.assertEqual(spec.env_var, "DSH_HOME")
self.assertEqual(spec.default_dirname, ".dsh")

dsh_home = self.root / "dsh-home"
install_integration("skill", yes=True, host_homes={"dsh": dsh_home})
avatar = dsh_home / "skills" / "dyro-control-plane"
self.assertTrue(avatar.is_symlink() or avatar.is_dir())
self.assertTrue((avatar / "SKILL.md").is_file())


if __name__ == "__main__":
unittest.main()
5 changes: 5 additions & 0 deletions tests/test_local_agent_dispatch_l1_l4.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,11 @@ def test_auto_and_echo(self) -> None:
rows = probe_backends()
self.assertTrue(any(r["id"] == "echo" for r in rows))
self.assertTrue(any(r["id"] == "opencode" and not r["supported"] for r in rows))
for provider_id in ("dsh", "pi"):
row = next(r for r in rows if r["id"] == provider_id)
self.assertFalse(row["supported"])
self.assertEqual(row["execution_kind"], "unintegrated")
self.assertEqual(row["command"], provider_id)

def test_routes_reject_simulation_and_unknown_providers(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
Expand Down
25 changes: 25 additions & 0 deletions tests/test_tooling.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from unittest.mock import patch

from dyro.errors import DyroError, ValidationError
from dyro.profile import launchable_preset_ids, preset_adapter
from dyro.tooling import (
ToolPreferences,
install_tool,
Expand Down Expand Up @@ -59,10 +60,30 @@ def test_catalog_includes_desktop_and_new_terminal_tools(self) -> None:
antigravity = tool_definition("antigravity")
codex_desktop = tool_definition("codex-desktop")
claude_desktop = tool_definition("claude-desktop")
dsh = tool_definition("dsh")
pi = tool_definition("pi")
qoder = tool_definition("qoder")
zcode = tool_definition("zcode")

self.assertEqual(antigravity.command if antigravity else "", "agy")
self.assertEqual(dsh.command if dsh else "", "dsh")
self.assertEqual(dsh.launch if dsh else (), ("dsh", "web"))
self.assertEqual(
dsh.install.argv if dsh and dsh.install else (),
("npm", "install", "-g", "@deepseek-ai/dsh@latest"),
)
self.assertEqual(pi.command if pi else "", "pi")
self.assertEqual(pi.launch if pi else (), ("pi",))
self.assertEqual(
pi.install.argv if pi and pi.install else (),
(
"npm",
"install",
"-g",
"--ignore-scripts",
"@earendil-works/pi-coding-agent@latest",
),
)
self.assertEqual(qoder.command if qoder else "", "qodercli")
self.assertEqual(zcode.interface if zcode else "", "desktop")
self.assertEqual(codex_desktop.interface if codex_desktop else "", "desktop")
Expand All @@ -71,6 +92,10 @@ def test_catalog_includes_desktop_and_new_terminal_tools(self) -> None:
qoder.install.argv if qoder and qoder.install else (),
("npm", "install", "-g", "@qoder-ai/qodercli"),
)
self.assertIn("dsh", launchable_preset_ids())
self.assertIn("pi", launchable_preset_ids())
self.assertEqual(preset_adapter("dsh", "dsh").launch, ("dsh", "web"))
self.assertEqual(preset_adapter("pi", "pi").launch, ("pi",))

def test_command_recipe_is_explicit_argv_and_requires_confirmation(self) -> None:
calls: list[tuple[str, ...]] = []
Expand Down