duet-cli 0.2.14__tar.gz → 0.2.17__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {duet_cli-0.2.14/duet_cli.egg-info → duet_cli-0.2.17}/PKG-INFO +55 -15
- {duet_cli-0.2.14 → duet_cli-0.2.17}/README.md +54 -14
- {duet_cli-0.2.14 → duet_cli-0.2.17}/duet.py +358 -5
- {duet_cli-0.2.14 → duet_cli-0.2.17/duet_cli.egg-info}/PKG-INFO +55 -15
- {duet_cli-0.2.14 → duet_cli-0.2.17}/duet_cli.egg-info/SOURCES.txt +3 -1
- {duet_cli-0.2.14 → duet_cli-0.2.17}/tests/test_duet.py +1 -1
- duet_cli-0.2.17/tests/test_loop_run_info.py +51 -0
- duet_cli-0.2.17/tests/test_zcode_backend.py +439 -0
- {duet_cli-0.2.14 → duet_cli-0.2.17}/LICENSE +0 -0
- {duet_cli-0.2.14 → duet_cli-0.2.17}/duet_cli.egg-info/dependency_links.txt +0 -0
- {duet_cli-0.2.14 → duet_cli-0.2.17}/duet_cli.egg-info/entry_points.txt +0 -0
- {duet_cli-0.2.14 → duet_cli-0.2.17}/duet_cli.egg-info/requires.txt +0 -0
- {duet_cli-0.2.14 → duet_cli-0.2.17}/duet_cli.egg-info/top_level.txt +0 -0
- {duet_cli-0.2.14 → duet_cli-0.2.17}/pyproject.toml +0 -0
- {duet_cli-0.2.14 → duet_cli-0.2.17}/setup.cfg +0 -0
- {duet_cli-0.2.14 → duet_cli-0.2.17}/tests/test_backend_metrics.py +0 -0
- {duet_cli-0.2.14 → duet_cli-0.2.17}/tests/test_bump_release_version.py +0 -0
- {duet_cli-0.2.14 → duet_cli-0.2.17}/tests/test_control_plane.py +0 -0
- {duet_cli-0.2.14 → duet_cli-0.2.17}/tests/test_feedback_concurrency.py +0 -0
- {duet_cli-0.2.14 → duet_cli-0.2.17}/tests/test_finding_reports.py +0 -0
- {duet_cli-0.2.14 → duet_cli-0.2.17}/tests/test_finding_workflow.py +0 -0
- {duet_cli-0.2.14 → duet_cli-0.2.17}/tests/test_metrics_cli.py +0 -0
- {duet_cli-0.2.14 → duet_cli-0.2.17}/tests/test_metrics_collection.py +0 -0
- {duet_cli-0.2.14 → duet_cli-0.2.17}/tests/test_metrics_lifecycle.py +0 -0
- {duet_cli-0.2.14 → duet_cli-0.2.17}/tests/test_metrics_reporting.py +0 -0
- {duet_cli-0.2.14 → duet_cli-0.2.17}/tests/test_no_subagents.py +0 -0
- {duet_cli-0.2.14 → duet_cli-0.2.17}/tests/test_stop_control_plane.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: duet-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.17
|
|
4
4
|
Summary: Two CLI agents in conversation. One Python file. Stdlib only.
|
|
5
5
|
Author: Volkan Altan
|
|
6
6
|
License-Expression: MIT
|
|
@@ -27,8 +27,11 @@ Duet runs two coding agents in alternating turns: one reviews or plans, the
|
|
|
27
27
|
other implements or challenges the result. Each keeps its own conversation,
|
|
28
28
|
and you get a saved transcript to inspect.
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
Run **two different models through a single agent harness (CLI)**, such as
|
|
31
|
+
Codex or OpenCode. You can also pair different CLIs, such as Claude Code and
|
|
32
|
+
Codex. Gemini and Copilot are also supported. A native ZCode account can
|
|
33
|
+
provide a [read-only review peer](https://github.com/volkan/duet/blob/main/docs/ZCODE.md).
|
|
34
|
+
[See examples using one CLI](https://github.com/volkan/duet/blob/main/docs/USAGE.md#same-backend-peering).
|
|
32
35
|
|
|
33
36
|
[](https://github.com/volkan/duet/blob/main/docs/demos/finding-review.mp4)
|
|
34
37
|
|
|
@@ -44,9 +47,11 @@ both the code and a test that had accepted the incorrect output.
|
|
|
44
47
|
|
|
45
48
|
## Quick start
|
|
46
49
|
|
|
47
|
-
You need Python 3.9+ and
|
|
48
|
-
[
|
|
49
|
-
[
|
|
50
|
+
You need Python 3.9+ and at least one chosen agent CLI, installed and signed
|
|
51
|
+
in. See [Claude Code installation](https://code.claude.com/docs/en/quickstart)
|
|
52
|
+
or [Codex CLI installation](https://developers.openai.com/codex/cli#getting-started).
|
|
53
|
+
Duet uses two separate sessions, which may use the same CLI with different
|
|
54
|
+
models.
|
|
50
55
|
**Only have Codex?** [Run two Codex models](https://github.com/volkan/duet/blob/main/docs/USAGE.md#codex-only-review).
|
|
51
56
|
For the Claude + Codex pair, run this from a Git repository:
|
|
52
57
|
|
|
@@ -59,21 +64,48 @@ This starts a review and fix loop with the coder in a separate Git worktree.
|
|
|
59
64
|
Inspect the changes before merging; agent agreement does not prove correctness.
|
|
60
65
|
|
|
61
66
|
Already working inside an agent? Install the **same Duet skill** for Claude
|
|
62
|
-
Code, Codex, and
|
|
67
|
+
Code, Codex, OpenCode, and ZCode (requires Node.js/npm; choose **Symlink**):
|
|
63
68
|
|
|
64
69
|
```bash
|
|
65
70
|
npx skills add volkan/duet --skill duet --global \
|
|
66
|
-
--agent claude-code --agent codex --agent opencode
|
|
71
|
+
--agent claude-code --agent codex --agent opencode --agent zcode
|
|
67
72
|
```
|
|
68
73
|
|
|
69
|
-
Start a new session
|
|
70
|
-
OpenCode to use the `duet` skill.
|
|
74
|
+
Start a new session. Use `/duet` in Claude Code, `$duet` in Codex, or `/skill duet` in ZCode.
|
|
75
|
+
Ask OpenCode to use the `duet` skill.
|
|
71
76
|
[Installation details](https://github.com/volkan/duet/blob/main/docs/INSTALLATION.md)
|
|
72
|
-
cover `~/.agents/skills`, prerequisites, and alternative installers.
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
[
|
|
76
|
-
|
|
77
|
+
cover `~/.agents/skills`, prerequisites, and alternative installers. See the
|
|
78
|
+
[OpenCode guide](https://github.com/volkan/duet/blob/main/docs/OPENCODE_PLUGIN.md)
|
|
79
|
+
for its native skill and optional command wrapper.
|
|
80
|
+
The [ZCode guide](https://github.com/volkan/duet/blob/main/docs/ZCODE.md#host-shortcuts)
|
|
81
|
+
provides its `/duet` shortcut and native peer setup.
|
|
82
|
+
|
|
83
|
+
## Native plugin installation
|
|
84
|
+
|
|
85
|
+
These optional native routes install the same shared Duet skill. Use one route
|
|
86
|
+
per host.
|
|
87
|
+
|
|
88
|
+
Codex, in a terminal:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
codex plugin marketplace add volkan/duet
|
|
92
|
+
codex plugin add duet@volkan-duet
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Start a new Codex session, then invoke `$duet`. See the [Codex installation
|
|
96
|
+
guide](https://github.com/volkan/duet/blob/main/docs/CODEX_PLUGIN.md#install-checklist)
|
|
97
|
+
for setup and marketplace recovery.
|
|
98
|
+
|
|
99
|
+
Claude Code, inside Claude Code:
|
|
100
|
+
|
|
101
|
+
```text
|
|
102
|
+
/plugin marketplace add volkan/duet
|
|
103
|
+
/plugin install duet@volkan-duet
|
|
104
|
+
/reload-plugins
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Then invoke `/duet:duet`. See the [Claude Code installation
|
|
108
|
+
guide](https://github.com/volkan/duet/blob/main/docs/CLAUDE_CODE_PLUGIN.md).
|
|
77
109
|
|
|
78
110
|
## Upgrade
|
|
79
111
|
|
|
@@ -84,6 +116,14 @@ pipx upgrade duet-cli
|
|
|
84
116
|
npx skills update duet --global
|
|
85
117
|
```
|
|
86
118
|
|
|
119
|
+
If pipx reports `already at latest version` despite a newer release on PyPI,
|
|
120
|
+
retry without pip's cache:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
pipx upgrade duet-cli --pip-args="--no-cache-dir"
|
|
124
|
+
duet --version
|
|
125
|
+
```
|
|
126
|
+
|
|
87
127
|
Start a new agent session afterward. For `uv`, `pip`, source checkouts, or
|
|
88
128
|
native marketplace plugins, follow the
|
|
89
129
|
[upgrade guide](https://github.com/volkan/duet/blob/main/docs/INSTALLATION.md#upgrade).
|
|
@@ -6,8 +6,11 @@ Duet runs two coding agents in alternating turns: one reviews or plans, the
|
|
|
6
6
|
other implements or challenges the result. Each keeps its own conversation,
|
|
7
7
|
and you get a saved transcript to inspect.
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
Run **two different models through a single agent harness (CLI)**, such as
|
|
10
|
+
Codex or OpenCode. You can also pair different CLIs, such as Claude Code and
|
|
11
|
+
Codex. Gemini and Copilot are also supported. A native ZCode account can
|
|
12
|
+
provide a [read-only review peer](https://github.com/volkan/duet/blob/main/docs/ZCODE.md).
|
|
13
|
+
[See examples using one CLI](https://github.com/volkan/duet/blob/main/docs/USAGE.md#same-backend-peering).
|
|
11
14
|
|
|
12
15
|
[](https://github.com/volkan/duet/blob/main/docs/demos/finding-review.mp4)
|
|
13
16
|
|
|
@@ -23,9 +26,11 @@ both the code and a test that had accepted the incorrect output.
|
|
|
23
26
|
|
|
24
27
|
## Quick start
|
|
25
28
|
|
|
26
|
-
You need Python 3.9+ and
|
|
27
|
-
[
|
|
28
|
-
[
|
|
29
|
+
You need Python 3.9+ and at least one chosen agent CLI, installed and signed
|
|
30
|
+
in. See [Claude Code installation](https://code.claude.com/docs/en/quickstart)
|
|
31
|
+
or [Codex CLI installation](https://developers.openai.com/codex/cli#getting-started).
|
|
32
|
+
Duet uses two separate sessions, which may use the same CLI with different
|
|
33
|
+
models.
|
|
29
34
|
**Only have Codex?** [Run two Codex models](https://github.com/volkan/duet/blob/main/docs/USAGE.md#codex-only-review).
|
|
30
35
|
For the Claude + Codex pair, run this from a Git repository:
|
|
31
36
|
|
|
@@ -38,21 +43,48 @@ This starts a review and fix loop with the coder in a separate Git worktree.
|
|
|
38
43
|
Inspect the changes before merging; agent agreement does not prove correctness.
|
|
39
44
|
|
|
40
45
|
Already working inside an agent? Install the **same Duet skill** for Claude
|
|
41
|
-
Code, Codex, and
|
|
46
|
+
Code, Codex, OpenCode, and ZCode (requires Node.js/npm; choose **Symlink**):
|
|
42
47
|
|
|
43
48
|
```bash
|
|
44
49
|
npx skills add volkan/duet --skill duet --global \
|
|
45
|
-
--agent claude-code --agent codex --agent opencode
|
|
50
|
+
--agent claude-code --agent codex --agent opencode --agent zcode
|
|
46
51
|
```
|
|
47
52
|
|
|
48
|
-
Start a new session
|
|
49
|
-
OpenCode to use the `duet` skill.
|
|
53
|
+
Start a new session. Use `/duet` in Claude Code, `$duet` in Codex, or `/skill duet` in ZCode.
|
|
54
|
+
Ask OpenCode to use the `duet` skill.
|
|
50
55
|
[Installation details](https://github.com/volkan/duet/blob/main/docs/INSTALLATION.md)
|
|
51
|
-
cover `~/.agents/skills`, prerequisites, and alternative installers.
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
[
|
|
55
|
-
|
|
56
|
+
cover `~/.agents/skills`, prerequisites, and alternative installers. See the
|
|
57
|
+
[OpenCode guide](https://github.com/volkan/duet/blob/main/docs/OPENCODE_PLUGIN.md)
|
|
58
|
+
for its native skill and optional command wrapper.
|
|
59
|
+
The [ZCode guide](https://github.com/volkan/duet/blob/main/docs/ZCODE.md#host-shortcuts)
|
|
60
|
+
provides its `/duet` shortcut and native peer setup.
|
|
61
|
+
|
|
62
|
+
## Native plugin installation
|
|
63
|
+
|
|
64
|
+
These optional native routes install the same shared Duet skill. Use one route
|
|
65
|
+
per host.
|
|
66
|
+
|
|
67
|
+
Codex, in a terminal:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
codex plugin marketplace add volkan/duet
|
|
71
|
+
codex plugin add duet@volkan-duet
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Start a new Codex session, then invoke `$duet`. See the [Codex installation
|
|
75
|
+
guide](https://github.com/volkan/duet/blob/main/docs/CODEX_PLUGIN.md#install-checklist)
|
|
76
|
+
for setup and marketplace recovery.
|
|
77
|
+
|
|
78
|
+
Claude Code, inside Claude Code:
|
|
79
|
+
|
|
80
|
+
```text
|
|
81
|
+
/plugin marketplace add volkan/duet
|
|
82
|
+
/plugin install duet@volkan-duet
|
|
83
|
+
/reload-plugins
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Then invoke `/duet:duet`. See the [Claude Code installation
|
|
87
|
+
guide](https://github.com/volkan/duet/blob/main/docs/CLAUDE_CODE_PLUGIN.md).
|
|
56
88
|
|
|
57
89
|
## Upgrade
|
|
58
90
|
|
|
@@ -63,6 +95,14 @@ pipx upgrade duet-cli
|
|
|
63
95
|
npx skills update duet --global
|
|
64
96
|
```
|
|
65
97
|
|
|
98
|
+
If pipx reports `already at latest version` despite a newer release on PyPI,
|
|
99
|
+
retry without pip's cache:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
pipx upgrade duet-cli --pip-args="--no-cache-dir"
|
|
103
|
+
duet --version
|
|
104
|
+
```
|
|
105
|
+
|
|
66
106
|
Start a new agent session afterward. For `uv`, `pip`, source checkouts, or
|
|
67
107
|
native marketplace plugins, follow the
|
|
68
108
|
[upgrade guide](https://github.com/volkan/duet/blob/main/docs/INSTALLATION.md#upgrade).
|
|
@@ -32,6 +32,8 @@ Each agent keeps its own session across turns:
|
|
|
32
32
|
`gemini --resume <session_id>` from the JSON response.
|
|
33
33
|
- Copilot: `copilot -p ... --output-format json`; subsequent turns use
|
|
34
34
|
`copilot --resume=<sessionId>` from the JSONL result event.
|
|
35
|
+
- ZCode: official headless JSON summaries and explicit --resume sess_ IDs;
|
|
36
|
+
private credential-free profiles select the account model and reasoning.
|
|
35
37
|
- OpenCode: `opencode run --format json`; subsequent turns use
|
|
36
38
|
`opencode run -s <sessionID>` from the JSONL event stream.
|
|
37
39
|
|
|
@@ -65,11 +67,12 @@ import threading
|
|
|
65
67
|
import time
|
|
66
68
|
import traceback
|
|
67
69
|
import uuid
|
|
70
|
+
import urllib.parse
|
|
68
71
|
from typing import Callable, Iterable, Optional
|
|
69
72
|
|
|
70
73
|
# ---------- defaults ----------
|
|
71
74
|
|
|
72
|
-
__version__ = "0.2.
|
|
75
|
+
__version__ = "0.2.17"
|
|
73
76
|
|
|
74
77
|
DEFAULT_SENTINEL = "<<<LGTM>>>"
|
|
75
78
|
DEFAULT_SANDBOX = "workspace-write"
|
|
@@ -422,6 +425,7 @@ class Agent:
|
|
|
422
425
|
# stays out of Agent's repr and serialized config/state shape; the run
|
|
423
426
|
# lifecycle records it alongside the turn when metrics are enabled.
|
|
424
427
|
last_call_metrics: dict = dataclasses.field(default_factory=dict, repr=False)
|
|
428
|
+
zcode_selection: Optional[dict] = None # native resume cannot change model/effort
|
|
425
429
|
|
|
426
430
|
def __post_init__(self) -> None:
|
|
427
431
|
# Pin Claude to its stable Sonnet alias instead of inheriting a
|
|
@@ -462,6 +466,8 @@ def agent_state(a: Agent) -> dict:
|
|
|
462
466
|
data["extra_args"] = a.extra_args
|
|
463
467
|
if a.reasoning_effort is not None:
|
|
464
468
|
data["reasoning_effort"] = a.reasoning_effort
|
|
469
|
+
if a.zcode_selection is not None:
|
|
470
|
+
data["zcode_selection"] = a.zcode_selection
|
|
465
471
|
return data
|
|
466
472
|
|
|
467
473
|
|
|
@@ -594,6 +600,11 @@ def validate_config(cfg: DuetConfig,
|
|
|
594
600
|
f"expected {choices}",
|
|
595
601
|
parser,
|
|
596
602
|
)
|
|
603
|
+
if agent.backend == "zcode":
|
|
604
|
+
try:
|
|
605
|
+
_zcode_requested_selection(agent, effective_reasoning(agent, cfg.reasoning))
|
|
606
|
+
except ValueError as exc:
|
|
607
|
+
_config_error(str(exc), parser)
|
|
597
608
|
if agent.name in seen_names:
|
|
598
609
|
_config_error(
|
|
599
610
|
f"duplicate agent name {agent.name!r}; agent names must be unique",
|
|
@@ -1598,6 +1609,8 @@ def _metrics_reasoning_profile(agent: Agent, cfg: DuetConfig) -> tuple[
|
|
|
1598
1609
|
return requested, None, None, "native_default", fast
|
|
1599
1610
|
transport = "prompt"
|
|
1600
1611
|
backend_value = None
|
|
1612
|
+
elif agent.backend == "zcode":
|
|
1613
|
+
transport = "config_file"
|
|
1601
1614
|
elif agent.backend == "codex":
|
|
1602
1615
|
transport = "config_flag"
|
|
1603
1616
|
else:
|
|
@@ -2470,6 +2483,335 @@ def _call_opencode_backend(agent: Agent, system_prompt: str, message: str,
|
|
|
2470
2483
|
)
|
|
2471
2484
|
|
|
2472
2485
|
|
|
2486
|
+
|
|
2487
|
+
_ZCODE_DESKTOP_RESOURCES = pathlib.Path("/Applications/ZCode.app/Contents/Resources")
|
|
2488
|
+
_ZCODE_SESSION_ID_RE = re.compile(r"^sess_[A-Za-z0-9_-]+$")
|
|
2489
|
+
_ZCODE_REVIEW_DENIED_TOOLS = CLAUDE_DENIED_DELEGATION_TOOLS + (
|
|
2490
|
+
"Bash", "Write", "Edit", "NotebookEdit", "ApplyPatch", "CreateWorkflow",
|
|
2491
|
+
"AmendWorkflow", "FillWorkflowHole", "ResumeWorkflowRun", "SaveWorkflow",
|
|
2492
|
+
"OffPeakCreate", "CronCreate", "CronUpdate", "CronDelete",
|
|
2493
|
+
"WebSearch", "WebFetch", "AskUserQuestion",
|
|
2494
|
+
"EnterPlanMode", "ExitPlanMode", "ReplyToChannel",
|
|
2495
|
+
)
|
|
2496
|
+
|
|
2497
|
+
|
|
2498
|
+
def _zcode_command() -> tuple[list[str], dict[str, str]]:
|
|
2499
|
+
"""Use the official CLI, or the installed macOS app's headless runtime."""
|
|
2500
|
+
cli = shutil.which("zcode")
|
|
2501
|
+
if cli:
|
|
2502
|
+
key = "ZCODE_BUILTIN_PROVIDER_CONFIG_FILE"
|
|
2503
|
+
source = os.environ.get(key, "").strip()
|
|
2504
|
+
if not source:
|
|
2505
|
+
directory = pathlib.Path(cli).resolve().parent
|
|
2506
|
+
candidates = (directory / "provider/zcode-builtin.json",
|
|
2507
|
+
(directory / "../../../../../config/provider/zcode-builtin.json").resolve())
|
|
2508
|
+
source = next((str(path) for path in candidates if path.is_file()), "")
|
|
2509
|
+
if not source:
|
|
2510
|
+
raise ValueError("Cannot locate the PATH ZCode CLI's catalog; set "
|
|
2511
|
+
"ZCODE_BUILTIN_PROVIDER_CONFIG_FILE to that installation's JSON catalog")
|
|
2512
|
+
return [cli], {key: source}
|
|
2513
|
+
entry = _ZCODE_DESKTOP_RESOURCES / "glm" / "zcode.cjs"
|
|
2514
|
+
builtin = _ZCODE_DESKTOP_RESOURCES / "config" / "provider" / "zcode-builtin.json"
|
|
2515
|
+
node = shutil.which("node")
|
|
2516
|
+
if sys.platform != "darwin" or not node or not entry.is_file() or not builtin.is_file():
|
|
2517
|
+
raise ValueError("ZCode CLI is not on PATH and no installed macOS ZCode runtime was found")
|
|
2518
|
+
return [node, str(entry)], {
|
|
2519
|
+
"ZCODE_BUILTIN_PROVIDER_CONFIG_FILE": str(builtin),
|
|
2520
|
+
}
|
|
2521
|
+
|
|
2522
|
+
|
|
2523
|
+
def _zcode_requested_selection(agent: Agent, reasoning: Optional[str]) -> dict:
|
|
2524
|
+
if agent.extra_args:
|
|
2525
|
+
raise ValueError("ZCode review backend does not accept extra_args; native policy must stay fixed")
|
|
2526
|
+
if agent.role not in {"reviewer", "triage-reviewer", "planner"}:
|
|
2527
|
+
raise ValueError("ZCode currently supports read-only reviewer/planner roles, not coder roles")
|
|
2528
|
+
provider, separator, model = (agent.model or "").partition("/")
|
|
2529
|
+
if not separator or not provider.startswith("account:") or not model:
|
|
2530
|
+
raise ValueError("ZCode requires its native account provider/model ID, e.g. "
|
|
2531
|
+
"account:zai-individual-coding-plan/GLM-5.3")
|
|
2532
|
+
pin = {"model": agent.model, "reasoning": reasoning}
|
|
2533
|
+
if agent.session_id:
|
|
2534
|
+
if not _ZCODE_SESSION_ID_RE.fullmatch(agent.session_id):
|
|
2535
|
+
raise ValueError("ZCode requires an explicit sess_ session ID; cwd-based resume is unsafe")
|
|
2536
|
+
if agent.zcode_selection != pin:
|
|
2537
|
+
raise ValueError("ZCode resume requires its original model/effort pin; start a fresh peer to change them")
|
|
2538
|
+
return {"providerId": provider, "modelId": model,
|
|
2539
|
+
**({"options": {"reasoningLevel": reasoning}} if reasoning else {})}
|
|
2540
|
+
|
|
2541
|
+
|
|
2542
|
+
|
|
2543
|
+
def _reject_zcode_json_constant(value: str):
|
|
2544
|
+
raise ValueError("ZCode configuration must use strict JSON")
|
|
2545
|
+
|
|
2546
|
+
|
|
2547
|
+
def _zcode_resolved_provider(config: dict, provider: dict) -> dict:
|
|
2548
|
+
concrete = provider["config"]
|
|
2549
|
+
template_id = provider.get("templateId")
|
|
2550
|
+
if not template_id:
|
|
2551
|
+
return concrete
|
|
2552
|
+
templates = [row for row in config["providerConfigRules"].get("templateRules", [])
|
|
2553
|
+
if row["templateId"] == template_id]
|
|
2554
|
+
if len(templates) != 1:
|
|
2555
|
+
raise ValueError("ZCode catalog has no unique provider template")
|
|
2556
|
+
base = templates[0]["config"]
|
|
2557
|
+
resolved = {**base, **concrete}
|
|
2558
|
+
if isinstance(base.get("api"), dict) and isinstance(concrete.get("api"), dict):
|
|
2559
|
+
resolved["api"] = {**base["api"], **concrete["api"]}
|
|
2560
|
+
return resolved
|
|
2561
|
+
|
|
2562
|
+
|
|
2563
|
+
def _zcode_catalog(selection: dict, launch_env: dict[str, str]) -> dict:
|
|
2564
|
+
source = launch_env.get("ZCODE_BUILTIN_PROVIDER_CONFIG_FILE") or os.environ.get(
|
|
2565
|
+
"ZCODE_BUILTIN_PROVIDER_CONFIG_FILE")
|
|
2566
|
+
if not source:
|
|
2567
|
+
raise ValueError("ZCode needs ZCODE_BUILTIN_PROVIDER_CONFIG_FILE for its selected runtime; "
|
|
2568
|
+
"a PATH CLI must not use another installation's catalog")
|
|
2569
|
+
data = json.loads(pathlib.Path(source).read_text(encoding="utf-8"),
|
|
2570
|
+
parse_constant=_reject_zcode_json_constant)
|
|
2571
|
+
config = data["config"]
|
|
2572
|
+
providers = config["providerConfigRules"]["providerRules"]
|
|
2573
|
+
matches = [row for row in providers if row["providerId"] == selection["providerId"]]
|
|
2574
|
+
if len(matches) != 1:
|
|
2575
|
+
raise ValueError("Requested ZCode account model is absent from its native catalog")
|
|
2576
|
+
provider = {**matches[0], "config": _zcode_resolved_provider(config, matches[0])}
|
|
2577
|
+
if selection["modelId"] not in provider["config"].get("builtinModelIds", []):
|
|
2578
|
+
raise ValueError("Requested ZCode account model is absent from its native catalog")
|
|
2579
|
+
levels = _zcode_catalog_reasoning(config["modelConfigRules"], provider, selection["modelId"])
|
|
2580
|
+
requested = selection.get("options", {}).get("reasoningLevel")
|
|
2581
|
+
if requested is not None and requested not in levels:
|
|
2582
|
+
raise ValueError("Requested ZCode reasoning level is absent from its native model catalog")
|
|
2583
|
+
return data
|
|
2584
|
+
|
|
2585
|
+
|
|
2586
|
+
def _zcode_rule_url(value: str) -> str:
|
|
2587
|
+
# Match native URL normalization for canonical HTTP(S) endpoints. Reject
|
|
2588
|
+
# exotic forms rather than validate an effort against a different site.
|
|
2589
|
+
if not re.fullmatch(r"[A-Za-z0-9:/?&#_~!$()*+,;=@.\[\]-]+", value):
|
|
2590
|
+
raise ValueError("Unsupported ZCode catalog API URL")
|
|
2591
|
+
url = urllib.parse.urlsplit(value)
|
|
2592
|
+
host = url.hostname or ""
|
|
2593
|
+
if (url.scheme not in {"http", "https"} or not host or url.username is not None
|
|
2594
|
+
or ":" in host or host.rstrip(".").rsplit(".", 1)[-1].isdigit()
|
|
2595
|
+
or host.rstrip(".").rsplit(".", 1)[-1].lower().startswith("0x")
|
|
2596
|
+
or re.search(r"(?:^|/)\.{1,2}(?:/|$)", url.path)
|
|
2597
|
+
or value.endswith(("?", "#")) or "?#" in value):
|
|
2598
|
+
raise ValueError("Unsupported ZCode catalog API URL")
|
|
2599
|
+
port = url.port
|
|
2600
|
+
authority = host.lower()
|
|
2601
|
+
if port is not None and port != {"http": 80, "https": 443}[url.scheme]:
|
|
2602
|
+
authority += ":" + str(port)
|
|
2603
|
+
endpoint = f"{url.scheme}://{authority}{url.path}".rstrip("/")
|
|
2604
|
+
return endpoint + ("?" + url.query if url.query else "") + ("#" + url.fragment if url.fragment else "")
|
|
2605
|
+
|
|
2606
|
+
|
|
2607
|
+
def _zcode_catalog_reasoning(rules: dict, provider: dict, model: str) -> list[str]:
|
|
2608
|
+
levels = []
|
|
2609
|
+
api = provider["config"].get("api") or {}
|
|
2610
|
+
subjects = {"modelMatch": model, "apiTypeMatch": api.get("type", ""),
|
|
2611
|
+
"baseUrlMatch": _zcode_rule_url(api["baseUrl"]) if api.get("baseUrl") else ""}
|
|
2612
|
+
for layer in ("modelRules", "modelApiRules", "providerSiteRules",
|
|
2613
|
+
"templateModelRules", "builtinProviderModelRules"):
|
|
2614
|
+
for row in rules.get(layer, []):
|
|
2615
|
+
if any(key in row and (not value or not re.fullmatch(
|
|
2616
|
+
row[key], value, re.IGNORECASE if key == "modelMatch" else 0))
|
|
2617
|
+
for key, value in subjects.items()):
|
|
2618
|
+
continue
|
|
2619
|
+
if row.get("modelId", model) != model:
|
|
2620
|
+
continue
|
|
2621
|
+
if row.get("providerId", provider["providerId"]) != provider["providerId"]:
|
|
2622
|
+
continue
|
|
2623
|
+
if "templateId" in row and row["templateId"] != provider.get("templateId"):
|
|
2624
|
+
continue
|
|
2625
|
+
specs = row.get("config", {}).get("optionSpecs", {})
|
|
2626
|
+
if specs is None or ("reasoningLevel" in specs and specs["reasoningLevel"] is None):
|
|
2627
|
+
levels = []
|
|
2628
|
+
continue
|
|
2629
|
+
reasoning = specs.get("reasoningLevel", {})
|
|
2630
|
+
values = reasoning.get("values")
|
|
2631
|
+
if "values" in reasoning and values is None:
|
|
2632
|
+
levels = []
|
|
2633
|
+
continue
|
|
2634
|
+
if values is not None:
|
|
2635
|
+
if not isinstance(values, list) or not all(isinstance(item, str) for item in values):
|
|
2636
|
+
raise ValueError("Unsupported ZCode native reasoning catalog shape")
|
|
2637
|
+
levels = values
|
|
2638
|
+
return levels
|
|
2639
|
+
|
|
2640
|
+
|
|
2641
|
+
def _zcode_permission_paths(cwd: pathlib.Path) -> list[pathlib.Path]:
|
|
2642
|
+
directory = pathlib.Path(os.path.abspath(cwd))
|
|
2643
|
+
ancestors = [directory, *directory.parents]
|
|
2644
|
+
directories = [directory]
|
|
2645
|
+
for index, parent in enumerate(ancestors):
|
|
2646
|
+
if (parent / ".git").exists():
|
|
2647
|
+
directories = list(reversed(ancestors[:index + 1]))
|
|
2648
|
+
break
|
|
2649
|
+
return [pathlib.Path.home() / ".zcode/cli/config.json", *[
|
|
2650
|
+
path for folder in directories for path in (folder / "zcode.json", folder / ".zcode/config.json")]]
|
|
2651
|
+
|
|
2652
|
+
|
|
2653
|
+
def _zcode_permission_denials(cwd: pathlib.Path) -> tuple[list[str], dict]:
|
|
2654
|
+
denied = list(_ZCODE_REVIEW_DENIED_TOOLS)
|
|
2655
|
+
fingerprints = {}
|
|
2656
|
+
mcp_disabled = False
|
|
2657
|
+
for path in _zcode_permission_paths(cwd):
|
|
2658
|
+
try:
|
|
2659
|
+
raw = path.read_bytes()
|
|
2660
|
+
except FileNotFoundError:
|
|
2661
|
+
fingerprints[path] = None
|
|
2662
|
+
continue
|
|
2663
|
+
except OSError:
|
|
2664
|
+
raise ValueError("Cannot read a native ZCode permission configuration") from None
|
|
2665
|
+
fingerprints[path] = hashlib.sha256(raw).hexdigest()
|
|
2666
|
+
try:
|
|
2667
|
+
data = json.loads(raw.decode("utf-8"), parse_constant=_reject_zcode_json_constant)
|
|
2668
|
+
permission = data.get("permission", {})
|
|
2669
|
+
rules = permission.get("disallowedTools", [])
|
|
2670
|
+
if (not isinstance(data, dict) or not isinstance(permission, dict)
|
|
2671
|
+
or not isinstance(rules, list) or not all(isinstance(rule, str) for rule in rules)
|
|
2672
|
+
or permission.get("allowedTools")):
|
|
2673
|
+
raise ValueError()
|
|
2674
|
+
if "features" in data:
|
|
2675
|
+
if not isinstance(data["features"], dict):
|
|
2676
|
+
raise ValueError()
|
|
2677
|
+
# Native ignores a whole invalid document. Certify only the
|
|
2678
|
+
# minimal schema we validate completely, not a richer file
|
|
2679
|
+
# whose unrelated fields could cause MCP=false to be skipped.
|
|
2680
|
+
mcp_disabled = (set(data) == {"features"}
|
|
2681
|
+
and set(data["features"]) == {"mcp"}
|
|
2682
|
+
and data["features"]["mcp"] is False)
|
|
2683
|
+
# CLI accepts comma/space-separated whole-tool names. Normalize
|
|
2684
|
+
# native command-pattern denials conservatively to the whole tool.
|
|
2685
|
+
for rule in rules:
|
|
2686
|
+
name = rule.strip().split("(", 1)[0].strip()
|
|
2687
|
+
if not name or re.search(r"[\s,]", name):
|
|
2688
|
+
raise ValueError()
|
|
2689
|
+
denied.append(name)
|
|
2690
|
+
except (ValueError, AttributeError, TypeError):
|
|
2691
|
+
raise ValueError("Unsupported native ZCode permission configuration; refusing to replace it") from None
|
|
2692
|
+
if not mcp_disabled:
|
|
2693
|
+
raise ValueError("ZCode review requires native features.mcp=false; plan mode permits some MCP writes")
|
|
2694
|
+
return list(dict.fromkeys(denied)), fingerprints
|
|
2695
|
+
|
|
2696
|
+
|
|
2697
|
+
def _zcode_check_permission_inputs(cwd: pathlib.Path, expected: dict) -> None:
|
|
2698
|
+
_, current = _zcode_permission_denials(cwd)
|
|
2699
|
+
if current != expected:
|
|
2700
|
+
raise ValueError("Native ZCode permission configuration changed before launch")
|
|
2701
|
+
|
|
2702
|
+
|
|
2703
|
+
@contextlib.contextmanager
|
|
2704
|
+
def _zcode_peer_profile(selection: dict, launch_env: dict[str, str]):
|
|
2705
|
+
# Native headless CLI has no model/effort flags. Its supported provider
|
|
2706
|
+
# profile carries only this peer's choice; auth stays in ZCode's store.
|
|
2707
|
+
catalog = _zcode_catalog(selection, launch_env)
|
|
2708
|
+
config = catalog["config"]
|
|
2709
|
+
providers = config["providerConfigRules"]["providerRules"]
|
|
2710
|
+
# Native defaults may fall back. Expose only the requested account/model;
|
|
2711
|
+
# an unavailable selection must fail before any other model is invoked.
|
|
2712
|
+
hidden = [{"providerId": row["providerId"], "config": {"visibility": "hidden"}}
|
|
2713
|
+
for row in providers if row["providerId"] != selection["providerId"]]
|
|
2714
|
+
disabled = [{"providerId": row["providerId"], "modelId": model,
|
|
2715
|
+
"config": {"enabled": False}}
|
|
2716
|
+
for row in providers
|
|
2717
|
+
for model in [*_zcode_resolved_provider(config, row).get("builtinModelIds", []),
|
|
2718
|
+
*_zcode_resolved_provider(config, row).get("personalModelIds", [])]
|
|
2719
|
+
if (row["providerId"], model) != (selection["providerId"], selection["modelId"])]
|
|
2720
|
+
reasoning = selection.get("options", {}).get("reasoningLevel")
|
|
2721
|
+
if reasoning is not None:
|
|
2722
|
+
# Narrow only after native-catalog validation. A session edited outside
|
|
2723
|
+
# Duet must not resume with another still-supported effort silently.
|
|
2724
|
+
disabled.append({"providerId": selection["providerId"], "modelId": selection["modelId"],
|
|
2725
|
+
"config": {"optionSpecs": {"reasoningLevel": {"values": [reasoning]}}}})
|
|
2726
|
+
with tempfile.TemporaryDirectory(prefix="duet-zcode-") as directory:
|
|
2727
|
+
profile = pathlib.Path(directory) / "provider.json"
|
|
2728
|
+
config = {"schemaVersion": 1, "config": {
|
|
2729
|
+
"providerConfigRules": {"providerRules": hidden},
|
|
2730
|
+
"modelConfigRules": {"providerModelRules": disabled, "manualProviderModelRules": []},
|
|
2731
|
+
"defaultModelSelection": selection,
|
|
2732
|
+
}}
|
|
2733
|
+
write_text_atomic(profile, json.dumps(config))
|
|
2734
|
+
profile.chmod(0o600)
|
|
2735
|
+
builtin = pathlib.Path(directory) / "builtin.json"
|
|
2736
|
+
write_text_atomic(builtin, json.dumps(catalog))
|
|
2737
|
+
builtin.chmod(0o600)
|
|
2738
|
+
yield {**launch_env, "ZCODE_PERSONAL_PROVIDER_CONFIG_FILE": str(profile),
|
|
2739
|
+
"ZCODE_BUILTIN_PROVIDER_CONFIG_FILE": str(builtin),
|
|
2740
|
+
"ZCODE_BUILTIN_PROVIDER_BUNDLED_CONFIG_FILE": ""}
|
|
2741
|
+
|
|
2742
|
+
|
|
2743
|
+
def _parse_zcode_reply(stdout: str) -> tuple[str, str]:
|
|
2744
|
+
try:
|
|
2745
|
+
result = json.loads(stdout)
|
|
2746
|
+
except (ValueError, TypeError) as exc:
|
|
2747
|
+
raise ValueError("ZCode did not return a JSON summary") from exc
|
|
2748
|
+
if not isinstance(result, dict):
|
|
2749
|
+
raise ValueError("ZCode JSON summary must be an object")
|
|
2750
|
+
session = result.get("sessionId")
|
|
2751
|
+
response = result.get("response")
|
|
2752
|
+
if not isinstance(session, str) or not _ZCODE_SESSION_ID_RE.fullmatch(session):
|
|
2753
|
+
raise ValueError("ZCode JSON summary has no valid sessionId")
|
|
2754
|
+
if not isinstance(response, str) or not response.strip():
|
|
2755
|
+
raise ValueError("ZCode JSON summary has no assistant response")
|
|
2756
|
+
projection = result.get("projection") or {}
|
|
2757
|
+
if result.get("error") or not isinstance(projection, dict) or projection.get("status") in {"error", "failed"}:
|
|
2758
|
+
raise ValueError("ZCode reported a failed turn")
|
|
2759
|
+
return response, session
|
|
2760
|
+
|
|
2761
|
+
|
|
2762
|
+
def call_zcode(agent: Agent, system_prompt: str, message: str,
|
|
2763
|
+
cwd: pathlib.Path, timeout: int, dry: bool,
|
|
2764
|
+
reasoning: Optional[str] = None,
|
|
2765
|
+
stderr_log_path: Optional[pathlib.Path] = None,
|
|
2766
|
+
pid_file_path: Optional[pathlib.Path] = None) -> tuple[str, Optional[str]]:
|
|
2767
|
+
selection = _zcode_requested_selection(agent, reasoning)
|
|
2768
|
+
pin = {"model": agent.model, "reasoning": reasoning}
|
|
2769
|
+
if dry:
|
|
2770
|
+
session = agent.session_id or "sess_dry_" + agent.name
|
|
2771
|
+
agent.zcode_selection = pin
|
|
2772
|
+
return (f"[dry-run zcode/{agent.name} reasoning={reasoning}] received {len(message)} chars\n"
|
|
2773
|
+
"LGTM rationale: dry-run validates the launch only, not a model response.\n"
|
|
2774
|
+
+ DEFAULT_SENTINEL), session
|
|
2775
|
+
try:
|
|
2776
|
+
command, launch_env = _zcode_command()
|
|
2777
|
+
native_cwd = pathlib.Path(agent.cwd_override or cwd)
|
|
2778
|
+
denied, permission_inputs = _zcode_permission_denials(native_cwd)
|
|
2779
|
+
command += ["--cwd", str(native_cwd), "--mode", "plan",
|
|
2780
|
+
"--workflow-mode", "disabled", "--json", "--no-color",
|
|
2781
|
+
"--disallowed-tools", ",".join(denied)]
|
|
2782
|
+
if agent.session_id:
|
|
2783
|
+
command += ["--resume", agent.session_id]
|
|
2784
|
+
command += ["--prompt", f"=== ROLE ===\n{system_prompt}\n\n=== MESSAGE FROM PARTNER ===\n{message}"]
|
|
2785
|
+
with _zcode_peer_profile(selection, launch_env) as env:
|
|
2786
|
+
_zcode_check_permission_inputs(native_cwd, permission_inputs)
|
|
2787
|
+
rc, out, err = _run(command, cwd=agent.cwd_override or cwd, stdin=None,
|
|
2788
|
+
timeout=timeout, stderr_log_path=stderr_log_path,
|
|
2789
|
+
pid_file_path=pid_file_path, env_overrides=env)
|
|
2790
|
+
if rc != 0:
|
|
2791
|
+
reason = FINISHED_TIMEOUT if rc == 124 else FINISHED_AGENT_ERROR
|
|
2792
|
+
raise AgentRunError(reason, _agent_exit_error("zcode", rc, out, err))
|
|
2793
|
+
response, session = _parse_zcode_reply(out)
|
|
2794
|
+
if agent.session_id and session != agent.session_id:
|
|
2795
|
+
raise ValueError("ZCode changed its pinned sessionId; refusing an unrelated conversation")
|
|
2796
|
+
except AgentRunError:
|
|
2797
|
+
raise
|
|
2798
|
+
except Exception as exc:
|
|
2799
|
+
raise AgentRunError(FINISHED_AGENT_ERROR, f"ZCode invocation failed: {exc}") from exc
|
|
2800
|
+
agent.zcode_selection = pin
|
|
2801
|
+
_metrics_record_raw_output(agent, out)
|
|
2802
|
+
return response, session
|
|
2803
|
+
|
|
2804
|
+
|
|
2805
|
+
def _call_zcode_backend(agent: Agent, system_prompt: str, message: str,
|
|
2806
|
+
cfg: DuetConfig, first_turn: bool,
|
|
2807
|
+
stderr_log_path: Optional[pathlib.Path],
|
|
2808
|
+
pid_file_path: Optional[pathlib.Path],
|
|
2809
|
+
reasoning: Optional[str]) -> tuple[str, Optional[str]]:
|
|
2810
|
+
return call_zcode(agent, system_prompt, message, cfg.cwd,
|
|
2811
|
+
cfg.per_turn_timeout, cfg.dry_run, reasoning,
|
|
2812
|
+
stderr_log_path, pid_file_path)
|
|
2813
|
+
|
|
2814
|
+
|
|
2473
2815
|
BACKENDS = {
|
|
2474
2816
|
"claude": BackendAdapter(
|
|
2475
2817
|
name="claude",
|
|
@@ -2500,6 +2842,10 @@ BACKENDS = {
|
|
|
2500
2842
|
reasoning_map=COPILOT_REASONING_MAP,
|
|
2501
2843
|
reasoning_prompt_prefix=COPILOT_REASONING_PROMPT_PREFIX,
|
|
2502
2844
|
),
|
|
2845
|
+
"zcode": BackendAdapter(
|
|
2846
|
+
name="zcode", binary="zcode", call=_call_zcode_backend,
|
|
2847
|
+
reasoning_map={level: level for level in REASONING_LEVELS}, reasoning_prompt_prefix={},
|
|
2848
|
+
),
|
|
2503
2849
|
"opencode": BackendAdapter(
|
|
2504
2850
|
name="opencode",
|
|
2505
2851
|
binary="opencode",
|
|
@@ -6386,6 +6732,7 @@ def _agents_from_state(state: dict,
|
|
|
6386
6732
|
role_prompt=(str(raw["role_prompt"]) if raw.get("role_prompt") else None),
|
|
6387
6733
|
model=(str(raw["model"]) if raw.get("model") else None),
|
|
6388
6734
|
session_id=(str(raw["session_id"]) if raw.get("session_id") else None),
|
|
6735
|
+
zcode_selection=raw.get("zcode_selection"),
|
|
6389
6736
|
extra_args=[str(x) for x in raw_extra_args],
|
|
6390
6737
|
reasoning_effort=(str(raw["reasoning_effort"])
|
|
6391
6738
|
if raw.get("reasoning_effort") else None),
|
|
@@ -7119,9 +7466,9 @@ def _build_arg_parser() -> argparse.ArgumentParser:
|
|
|
7119
7466
|
ap.add_argument("--task-from-cmd", metavar="CMD",
|
|
7120
7467
|
help="run shell command with cwd=--cwd and use stdout as the task")
|
|
7121
7468
|
ap.add_argument("--partner", default=None,
|
|
7122
|
-
help="partner agent spec, e.g. codex:coder, claude:reviewer, gemini:coder, copilot:coder, opencode:coder (default codex:coder)")
|
|
7469
|
+
help="partner agent spec, e.g. codex:coder, claude:reviewer, gemini:coder, copilot:coder, opencode:coder, zcode:reviewer (default codex:coder)")
|
|
7123
7470
|
ap.add_argument("--lead", default=None,
|
|
7124
|
-
help="lead agent spec, e.g. claude:planner, gemini:reviewer, copilot:planner, opencode:planner (default; ignored if --resume-claude given)")
|
|
7471
|
+
help="lead agent spec, e.g. claude:planner, gemini:reviewer, copilot:planner, opencode:planner, zcode:reviewer (default; ignored if --resume-claude given)")
|
|
7125
7472
|
ap.add_argument("--lead-model", metavar="MODEL", default=None,
|
|
7126
7473
|
help="lead backend model via --model; Claude defaults to 'sonnet'")
|
|
7127
7474
|
ap.add_argument("--partner-model", metavar="MODEL", default=None,
|
|
@@ -7153,9 +7500,9 @@ def _build_arg_parser() -> argparse.ArgumentParser:
|
|
|
7153
7500
|
help="atomically write the new run's schema-v1 launch JSON; "
|
|
7154
7501
|
"refuses to overwrite FILE")
|
|
7155
7502
|
ap.add_argument("--sandbox", default=None,
|
|
7156
|
-
help="codex --sandbox: read-only|workspace-write|danger-full-access. No-op for claude/gemini/copilot/opencode.")
|
|
7503
|
+
help="codex --sandbox: read-only|workspace-write|danger-full-access. No-op for claude/gemini/copilot/opencode/zcode.")
|
|
7157
7504
|
ap.add_argument("--permission-mode", default=None,
|
|
7158
|
-
help="claude --permission-mode; gemini maps default/acceptEdits/plan/bypassPermissions to --approval-mode. No-op for copilot/opencode.")
|
|
7505
|
+
help="claude --permission-mode; gemini maps default/acceptEdits/plan/bypassPermissions to --approval-mode. No-op for copilot/opencode/zcode.")
|
|
7159
7506
|
ap.add_argument("--config", help="optional YAML/JSON config (overrides flags except --resume-*)")
|
|
7160
7507
|
worktree_group = ap.add_mutually_exclusive_group()
|
|
7161
7508
|
worktree_group.add_argument(
|
|
@@ -7637,6 +7984,12 @@ def _warn_missing_backends(cfg: DuetConfig) -> None:
|
|
|
7637
7984
|
return
|
|
7638
7985
|
for backend in {a.backend for a in cfg.agents}:
|
|
7639
7986
|
binary = backend_adapter(backend).binary
|
|
7987
|
+
if backend == "zcode":
|
|
7988
|
+
try:
|
|
7989
|
+
_zcode_command()
|
|
7990
|
+
continue
|
|
7991
|
+
except ValueError:
|
|
7992
|
+
pass
|
|
7640
7993
|
if shutil.which(binary) is None:
|
|
7641
7994
|
print(
|
|
7642
7995
|
f"[duet] WARNING: '{binary}' not on PATH — this run will fail. "
|