duet-cli 0.2.17__tar.gz → 0.2.18__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.
Files changed (28) hide show
  1. {duet_cli-0.2.17/duet_cli.egg-info → duet_cli-0.2.18}/PKG-INFO +1 -1
  2. {duet_cli-0.2.17 → duet_cli-0.2.18}/duet.py +190 -34
  3. {duet_cli-0.2.17 → duet_cli-0.2.18/duet_cli.egg-info}/PKG-INFO +1 -1
  4. duet_cli-0.2.18/tests/test_zcode_backend.py +908 -0
  5. duet_cli-0.2.17/tests/test_zcode_backend.py +0 -439
  6. {duet_cli-0.2.17 → duet_cli-0.2.18}/LICENSE +0 -0
  7. {duet_cli-0.2.17 → duet_cli-0.2.18}/README.md +0 -0
  8. {duet_cli-0.2.17 → duet_cli-0.2.18}/duet_cli.egg-info/SOURCES.txt +0 -0
  9. {duet_cli-0.2.17 → duet_cli-0.2.18}/duet_cli.egg-info/dependency_links.txt +0 -0
  10. {duet_cli-0.2.17 → duet_cli-0.2.18}/duet_cli.egg-info/entry_points.txt +0 -0
  11. {duet_cli-0.2.17 → duet_cli-0.2.18}/duet_cli.egg-info/requires.txt +0 -0
  12. {duet_cli-0.2.17 → duet_cli-0.2.18}/duet_cli.egg-info/top_level.txt +0 -0
  13. {duet_cli-0.2.17 → duet_cli-0.2.18}/pyproject.toml +0 -0
  14. {duet_cli-0.2.17 → duet_cli-0.2.18}/setup.cfg +0 -0
  15. {duet_cli-0.2.17 → duet_cli-0.2.18}/tests/test_backend_metrics.py +0 -0
  16. {duet_cli-0.2.17 → duet_cli-0.2.18}/tests/test_bump_release_version.py +0 -0
  17. {duet_cli-0.2.17 → duet_cli-0.2.18}/tests/test_control_plane.py +0 -0
  18. {duet_cli-0.2.17 → duet_cli-0.2.18}/tests/test_duet.py +0 -0
  19. {duet_cli-0.2.17 → duet_cli-0.2.18}/tests/test_feedback_concurrency.py +0 -0
  20. {duet_cli-0.2.17 → duet_cli-0.2.18}/tests/test_finding_reports.py +0 -0
  21. {duet_cli-0.2.17 → duet_cli-0.2.18}/tests/test_finding_workflow.py +0 -0
  22. {duet_cli-0.2.17 → duet_cli-0.2.18}/tests/test_loop_run_info.py +0 -0
  23. {duet_cli-0.2.17 → duet_cli-0.2.18}/tests/test_metrics_cli.py +0 -0
  24. {duet_cli-0.2.17 → duet_cli-0.2.18}/tests/test_metrics_collection.py +0 -0
  25. {duet_cli-0.2.17 → duet_cli-0.2.18}/tests/test_metrics_lifecycle.py +0 -0
  26. {duet_cli-0.2.17 → duet_cli-0.2.18}/tests/test_metrics_reporting.py +0 -0
  27. {duet_cli-0.2.17 → duet_cli-0.2.18}/tests/test_no_subagents.py +0 -0
  28. {duet_cli-0.2.17 → duet_cli-0.2.18}/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.17
3
+ Version: 0.2.18
4
4
  Summary: Two CLI agents in conversation. One Python file. Stdlib only.
5
5
  Author: Volkan Altan
6
6
  License-Expression: MIT
@@ -72,7 +72,7 @@ from typing import Callable, Iterable, Optional
72
72
 
73
73
  # ---------- defaults ----------
74
74
 
75
- __version__ = "0.2.17"
75
+ __version__ = "0.2.18"
76
76
 
77
77
  DEFAULT_SENTINEL = "<<<LGTM>>>"
78
78
  DEFAULT_SANDBOX = "workspace-write"
@@ -2495,6 +2495,25 @@ _ZCODE_REVIEW_DENIED_TOOLS = CLAUDE_DENIED_DELEGATION_TOOLS + (
2495
2495
  )
2496
2496
 
2497
2497
 
2498
+ def _zcode_missing_catalog_error(cli: str) -> ValueError:
2499
+ example = " ZCODE_BUILTIN_PROVIDER_CONFIG_FILE=/path/to/that/installation/zcode-builtin.json duet ..."
2500
+ head = "Cannot locate the PATH ZCode CLI's catalog"
2501
+ if os.path.islink(cli):
2502
+ lines = [f"{head}: no catalog is in a known place for its entry file {pathlib.Path(cli).resolve()}.",
2503
+ "Set the catalog on the Duet command only, to that installation's JSON catalog:", example]
2504
+ else:
2505
+ lines = [f"{head}: {cli} is not a link to a ZCode entry file, for example because it is a wrapper script.",
2506
+ f"If {cli} runs one ZCode installation, replace it with a symbolic link to that installation's entry file.",
2507
+ "If it selects between installations or is a self-contained executable, set the catalog on the "
2508
+ "Duet command only, for the installation that runs headless calls:", example]
2509
+ desktop = _ZCODE_DESKTOP_RESOURCES / "config" / "provider" / "zcode-builtin.json"
2510
+ if sys.platform == "darwin" and desktop.is_file():
2511
+ lines.append(f"If that installation is the macOS desktop app, its catalog is {desktop}.")
2512
+ lines.append("Do not export the variable in a shell where a wrapper selects between installations. "
2513
+ "Duet never uses another installation's catalog. See docs/ZCODE.md#desktop-cli-in-a-terminal.")
2514
+ return ValueError("\n".join(lines))
2515
+
2516
+
2498
2517
  def _zcode_command() -> tuple[list[str], dict[str, str]]:
2499
2518
  """Use the official CLI, or the installed macOS app's headless runtime."""
2500
2519
  cli = shutil.which("zcode")
@@ -2502,13 +2521,16 @@ def _zcode_command() -> tuple[list[str], dict[str, str]]:
2502
2521
  key = "ZCODE_BUILTIN_PROVIDER_CONFIG_FILE"
2503
2522
  source = os.environ.get(key, "").strip()
2504
2523
  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())
2524
+ entry = pathlib.Path(cli).resolve()
2525
+ directory = entry.parent
2526
+ candidates = [directory / "provider/zcode-builtin.json"]
2527
+ if directory.name == "glm" and entry.name == "zcode.cjs":
2528
+ # Only the desktop bundle layout (resources/glm/zcode.cjs beside resources/config) may use a sibling catalog.
2529
+ candidates.append(directory.parent / "config/provider/zcode-builtin.json")
2530
+ candidates.append((directory / "../../../../../config/provider/zcode-builtin.json").resolve())
2508
2531
  source = next((str(path) for path in candidates if path.is_file()), "")
2509
2532
  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")
2533
+ raise _zcode_missing_catalog_error(cli)
2512
2534
  return [cli], {key: source}
2513
2535
  entry = _ZCODE_DESKTOP_RESOURCES / "glm" / "zcode.cjs"
2514
2536
  builtin = _ZCODE_DESKTOP_RESOURCES / "config" / "provider" / "zcode-builtin.json"
@@ -2560,6 +2582,21 @@ def _zcode_resolved_provider(config: dict, provider: dict) -> dict:
2560
2582
  return resolved
2561
2583
 
2562
2584
 
2585
+ def _zcode_reasoning_error(selection: dict, requested: str, levels: list[str]) -> ValueError:
2586
+ head = ("Requested ZCode reasoning level is absent from its native model catalog: "
2587
+ f"{requested} for {selection['providerId']}/{selection['modelId']}.")
2588
+ if levels:
2589
+ return ValueError(
2590
+ f"{head} Supported levels: {', '.join(levels)}.\n"
2591
+ "A run-level --reasoning applies to both agents. To give this agent a supported level, "
2592
+ "set its reasoning_effort in a --config file. See docs/ZCODE.md#native-review-peer.")
2593
+ return ValueError(
2594
+ f"{head} The catalog lists no reasoning levels for this model.\n"
2595
+ "Do not set reasoning for this agent. A run-level --reasoning applies to both agents, "
2596
+ "so set reasoning_effort only for the other agent in a --config file. "
2597
+ "See docs/ZCODE.md#native-review-peer.")
2598
+
2599
+
2563
2600
  def _zcode_catalog(selection: dict, launch_env: dict[str, str]) -> dict:
2564
2601
  source = launch_env.get("ZCODE_BUILTIN_PROVIDER_CONFIG_FILE") or os.environ.get(
2565
2602
  "ZCODE_BUILTIN_PROVIDER_CONFIG_FILE")
@@ -2579,7 +2616,7 @@ def _zcode_catalog(selection: dict, launch_env: dict[str, str]) -> dict:
2579
2616
  levels = _zcode_catalog_reasoning(config["modelConfigRules"], provider, selection["modelId"])
2580
2617
  requested = selection.get("options", {}).get("reasoningLevel")
2581
2618
  if requested is not None and requested not in levels:
2582
- raise ValueError("Requested ZCode reasoning level is absent from its native model catalog")
2619
+ raise _zcode_reasoning_error(selection, requested, levels)
2583
2620
  return data
2584
2621
 
2585
2622
 
@@ -2638,22 +2675,53 @@ def _zcode_catalog_reasoning(rules: dict, provider: dict, model: str) -> list[st
2638
2675
  return levels
2639
2676
 
2640
2677
 
2641
- def _zcode_permission_paths(cwd: pathlib.Path) -> list[pathlib.Path]:
2678
+ def _zcode_project_directories(cwd: pathlib.Path) -> list[pathlib.Path]:
2642
2679
  directory = pathlib.Path(os.path.abspath(cwd))
2643
2680
  ancestors = [directory, *directory.parents]
2644
- directories = [directory]
2645
2681
  for index, parent in enumerate(ancestors):
2646
2682
  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")]]
2683
+ return list(reversed(ancestors[:index + 1]))
2684
+ return [directory]
2651
2685
 
2652
2686
 
2653
- def _zcode_permission_denials(cwd: pathlib.Path) -> tuple[list[str], dict]:
2687
+ def _zcode_permission_paths(cwd: pathlib.Path) -> list[pathlib.Path]:
2688
+ return [pathlib.Path.home() / ".zcode/cli/config.json", *[
2689
+ path for folder in _zcode_project_directories(cwd)
2690
+ for path in (folder / "zcode.json", folder / ".zcode/config.json")]]
2691
+
2692
+
2693
+ def _zcode_mcp_gate_error(fingerprints: dict, features_path: Optional[pathlib.Path],
2694
+ cwd: pathlib.Path, origin: Optional[pathlib.Path]) -> ValueError:
2695
+ paths = list(fingerprints)
2696
+ project_features = features_path if features_path in paths[1:] else None
2697
+ start = paths.index(project_features) + 1 if project_features else 1
2698
+ target = next((path for path in reversed(paths[start:]) if fingerprints[path] is None), None)
2699
+ document = '{"features": {"mcp": false}}'
2700
+ verb, path = ("create", target) if target else ("change", project_features or paths[-1])
2701
+ if origin:
2702
+ verb += " and commit"
2703
+ path = origin / path.relative_to(pathlib.Path(os.path.abspath(cwd)))
2704
+ action = f"{verb} {path} {'with' if target else 'to'} exactly {document}"
2705
+ lost = "" if target else f" Other settings in {path} are lost, so move any that you need to another file first."
2706
+ folders = ", ".join(str(path.parent) for path in paths[1::2])
2707
+ scope = " of the worktree" if origin else ""
2708
+ return ValueError(
2709
+ f"ZCode review requires native features.mcp=false: {action}\n"
2710
+ "The file must contain only this document. Duet does not accept MCP=false from a file "
2711
+ f"that has other settings.{lost}\n"
2712
+ f"Duet reads {paths[0]}, then zcode.json and .zcode/config.json in each of these folders{scope}: "
2713
+ f"{folders}. The last file that sets \"features\" decides.\n"
2714
+ "Plan mode permits some MCP writes, so Duet does not start a ZCode review without this setting.\n"
2715
+ "A file that is not committed does not reach a new Duet worktree. "
2716
+ "See docs/ZCODE.md#native-mcp-prerequisite.")
2717
+
2718
+
2719
+ def _zcode_permission_denials(cwd: pathlib.Path,
2720
+ origin: Optional[pathlib.Path] = None) -> tuple[list[str], dict]:
2654
2721
  denied = list(_ZCODE_REVIEW_DENIED_TOOLS)
2655
2722
  fingerprints = {}
2656
2723
  mcp_disabled = False
2724
+ features_path = None
2657
2725
  for path in _zcode_permission_paths(cwd):
2658
2726
  try:
2659
2727
  raw = path.read_bytes()
@@ -2674,6 +2742,7 @@ def _zcode_permission_denials(cwd: pathlib.Path) -> tuple[list[str], dict]:
2674
2742
  if "features" in data:
2675
2743
  if not isinstance(data["features"], dict):
2676
2744
  raise ValueError()
2745
+ features_path = path
2677
2746
  # Native ignores a whole invalid document. Certify only the
2678
2747
  # minimal schema we validate completely, not a richer file
2679
2748
  # whose unrelated fields could cause MCP=false to be skipped.
@@ -2690,7 +2759,7 @@ def _zcode_permission_denials(cwd: pathlib.Path) -> tuple[list[str], dict]:
2690
2759
  except (ValueError, AttributeError, TypeError):
2691
2760
  raise ValueError("Unsupported native ZCode permission configuration; refusing to replace it") from None
2692
2761
  if not mcp_disabled:
2693
- raise ValueError("ZCode review requires native features.mcp=false; plan mode permits some MCP writes")
2762
+ raise _zcode_mcp_gate_error(fingerprints, features_path, cwd, origin)
2694
2763
  return list(dict.fromkeys(denied)), fingerprints
2695
2764
 
2696
2765
 
@@ -2740,6 +2809,33 @@ def _zcode_peer_profile(selection: dict, launch_env: dict[str, str]):
2740
2809
  "ZCODE_BUILTIN_PROVIDER_BUNDLED_CONFIG_FILE": ""}
2741
2810
 
2742
2811
 
2812
+ _ZCODE_FAILURE_HINTS = (
2813
+ ("project_token_login_failed",
2814
+ "[duet] hint: ZCode could not get a project token from the account service. "
2815
+ "Make sure that the network is available. Then sign in again with the login "
2816
+ "command in docs/ZCODE.md#account-initialization."),
2817
+ ("project_token_login_required",
2818
+ "[duet] hint: ZCode has no stored login token or account identity. "
2819
+ "Sign in with the login command in docs/ZCODE.md#account-initialization."),
2820
+ ("Select a model before",
2821
+ "[duet] hint: ZCode has no usable model. Sign in again with the login command "
2822
+ "in docs/ZCODE.md#account-initialization, and make sure that the provider ID "
2823
+ "belongs to the connected account."),
2824
+ )
2825
+
2826
+
2827
+ def _zcode_failure_hint(rc: int, out: str, err: str) -> str:
2828
+ if rc == 124:
2829
+ return ""
2830
+ try:
2831
+ result = json.loads(out)
2832
+ except (ValueError, TypeError):
2833
+ result = None
2834
+ text = str(result.get("error") or "") if isinstance(result, dict) else ""
2835
+ text += "\n" + err
2836
+ return next(("\n" + line for marker, line in _ZCODE_FAILURE_HINTS if marker in text), "")
2837
+
2838
+
2743
2839
  def _parse_zcode_reply(stdout: str) -> tuple[str, str]:
2744
2840
  try:
2745
2841
  result = json.loads(stdout)
@@ -2763,7 +2859,8 @@ def call_zcode(agent: Agent, system_prompt: str, message: str,
2763
2859
  cwd: pathlib.Path, timeout: int, dry: bool,
2764
2860
  reasoning: Optional[str] = None,
2765
2861
  stderr_log_path: Optional[pathlib.Path] = None,
2766
- pid_file_path: Optional[pathlib.Path] = None) -> tuple[str, Optional[str]]:
2862
+ pid_file_path: Optional[pathlib.Path] = None,
2863
+ origin: Optional[pathlib.Path] = None) -> tuple[str, Optional[str]]:
2767
2864
  selection = _zcode_requested_selection(agent, reasoning)
2768
2865
  pin = {"model": agent.model, "reasoning": reasoning}
2769
2866
  if dry:
@@ -2775,7 +2872,7 @@ def call_zcode(agent: Agent, system_prompt: str, message: str,
2775
2872
  try:
2776
2873
  command, launch_env = _zcode_command()
2777
2874
  native_cwd = pathlib.Path(agent.cwd_override or cwd)
2778
- denied, permission_inputs = _zcode_permission_denials(native_cwd)
2875
+ denied, permission_inputs = _zcode_permission_denials(native_cwd, origin)
2779
2876
  command += ["--cwd", str(native_cwd), "--mode", "plan",
2780
2877
  "--workflow-mode", "disabled", "--json", "--no-color",
2781
2878
  "--disallowed-tools", ",".join(denied)]
@@ -2787,10 +2884,14 @@ def call_zcode(agent: Agent, system_prompt: str, message: str,
2787
2884
  rc, out, err = _run(command, cwd=agent.cwd_override or cwd, stdin=None,
2788
2885
  timeout=timeout, stderr_log_path=stderr_log_path,
2789
2886
  pid_file_path=pid_file_path, env_overrides=env)
2887
+ hint = _zcode_failure_hint(rc, out, err)
2790
2888
  if rc != 0:
2791
2889
  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)
2890
+ raise AgentRunError(reason, _agent_exit_error("zcode", rc, out, err, hint))
2891
+ try:
2892
+ response, session = _parse_zcode_reply(out)
2893
+ except ValueError as exc:
2894
+ raise ValueError(f"{exc}{hint}") from exc
2794
2895
  if agent.session_id and session != agent.session_id:
2795
2896
  raise ValueError("ZCode changed its pinned sessionId; refusing an unrelated conversation")
2796
2897
  except AgentRunError:
@@ -2802,6 +2903,35 @@ def call_zcode(agent: Agent, system_prompt: str, message: str,
2802
2903
  return response, session
2803
2904
 
2804
2905
 
2906
+ def _zcode_worktree_origin(agent: Agent, cfg: DuetConfig) -> Optional[pathlib.Path]:
2907
+ created = cfg.worktree and not cfg.worktree_path
2908
+ return _zcode_project_directories(cfg.cwd)[0] if created and agent.cwd_override else None
2909
+
2910
+
2911
+ def _zcode_launch_check(agent: Agent, cfg: DuetConfig, native_cwd: Optional[pathlib.Path]) -> None:
2912
+ selection = _zcode_requested_selection(agent, effective_reasoning(agent, cfg.reasoning))
2913
+ _, launch_env = _zcode_command()
2914
+ if native_cwd:
2915
+ _zcode_permission_denials(native_cwd, _zcode_worktree_origin(agent, cfg))
2916
+ with _zcode_peer_profile(selection, launch_env):
2917
+ pass
2918
+
2919
+
2920
+ def _zcode_preflight(cfg: DuetConfig, *, worktree_ready: bool) -> Optional[str]:
2921
+ if cfg.dry_run:
2922
+ return None
2923
+ deferred = _worktree_agent_index(cfg) if cfg.worktree or cfg.worktree_path else -1
2924
+ for index, agent in enumerate(cfg.agents):
2925
+ if agent.backend != "zcode" or (worktree_ready and index != deferred):
2926
+ continue
2927
+ native_cwd = None if index == deferred and not worktree_ready else pathlib.Path(agent.cwd_override or cfg.cwd)
2928
+ try:
2929
+ _zcode_launch_check(agent, cfg, native_cwd)
2930
+ except Exception as exc:
2931
+ return f"ZCode launch check failed for agent {agent.name}: {exc}"
2932
+ return None
2933
+
2934
+
2805
2935
  def _call_zcode_backend(agent: Agent, system_prompt: str, message: str,
2806
2936
  cfg: DuetConfig, first_turn: bool,
2807
2937
  stderr_log_path: Optional[pathlib.Path],
@@ -2809,7 +2939,8 @@ def _call_zcode_backend(agent: Agent, system_prompt: str, message: str,
2809
2939
  reasoning: Optional[str]) -> tuple[str, Optional[str]]:
2810
2940
  return call_zcode(agent, system_prompt, message, cfg.cwd,
2811
2941
  cfg.per_turn_timeout, cfg.dry_run, reasoning,
2812
- stderr_log_path, pid_file_path)
2942
+ stderr_log_path, pid_file_path,
2943
+ _zcode_worktree_origin(agent, cfg))
2813
2944
 
2814
2945
 
2815
2946
  BACKENDS = {
@@ -3239,6 +3370,10 @@ def _worktree_setup_failed(cfg: DuetConfig, message: str) -> None:
3239
3370
  file=sys.stderr)
3240
3371
 
3241
3372
 
3373
+ def _worktree_agent_index(cfg: DuetConfig) -> int:
3374
+ return {"lead": 0, "partner": 1}.get(cfg.worktree_for, 1)
3375
+
3376
+
3242
3377
  def _setup_run_worktree(
3243
3378
  cfg: DuetConfig, run_id: str, run_dir: pathlib.Path,
3244
3379
  ) -> tuple[Optional[pathlib.Path], Optional[str]]:
@@ -3251,7 +3386,7 @@ def _setup_run_worktree(
3251
3386
  with a durable setup failure. A failed *create* still reports the intended
3252
3387
  branch name, matching the pre-extraction behavior.
3253
3388
  """
3254
- wt_idx = {"lead": 0, "partner": 1}.get(cfg.worktree_for, 1)
3389
+ wt_idx = _worktree_agent_index(cfg)
3255
3390
  if cfg.worktree_path:
3256
3391
  existing = pathlib.Path(cfg.worktree_path).expanduser().resolve()
3257
3392
  if not existing.is_dir():
@@ -4245,6 +4380,21 @@ def _print_run_banner(cfg: DuetConfig, context: _RunContext) -> None:
4245
4380
  f"{cfg.agents[0].session_id}")
4246
4381
 
4247
4382
 
4383
+ def _zcode_preflight_failure(cfg: DuetConfig, context: _RunContext, *,
4384
+ worktree_ready: bool) -> Optional[dict]:
4385
+ message = _zcode_preflight(cfg, worktree_ready=worktree_ready)
4386
+ if message is None:
4387
+ return None
4388
+ state = _launch_failure_state(
4389
+ cfg, reason=FINISHED_SETUP_ERROR, message=message,
4390
+ history=context.history, transcript_path=context.transcript_path,
4391
+ recap_path=context.recap_path, state_path=context.state_path,
4392
+ wt_path=context.wt_path, wt_branch=context.wt_branch,
4393
+ )
4394
+ _print_worktree_notice(cfg, context.wt_path, context.wt_branch)
4395
+ return state
4396
+
4397
+
4248
4398
  def _prepare_run(cfg: DuetConfig) -> tuple[_RunContext, Optional[dict]]:
4249
4399
  """Allocate, publish launch metadata, isolate, and run deferred kickoff."""
4250
4400
  _validate_run_info_target(cfg)
@@ -4305,6 +4455,9 @@ def _prepare_run(cfg: DuetConfig) -> tuple[_RunContext, Optional[dict]]:
4305
4455
  return context, state
4306
4456
 
4307
4457
  _print_run_banner(cfg, context)
4458
+ failure = _zcode_preflight_failure(cfg, context, worktree_ready=False)
4459
+ if failure is not None:
4460
+ return context, failure
4308
4461
  _probe_run_metrics(cfg)
4309
4462
  failure = _run_kickoff_command(
4310
4463
  cfg, run_dir=run_dir, transcript_path=transcript_path,
@@ -4341,6 +4494,9 @@ def _prepare_run(cfg: DuetConfig) -> tuple[_RunContext, Optional[dict]]:
4341
4494
  )
4342
4495
  return context, state
4343
4496
 
4497
+ failure = _zcode_preflight_failure(cfg, context, worktree_ready=True)
4498
+ if failure is not None:
4499
+ return context, failure
4344
4500
  setup_state = _build_run_state(
4345
4501
  cfg, turns_used=0, history=history, finished_reason=None,
4346
4502
  transcript_path=transcript_path, recap_path=recap_path,
@@ -4351,6 +4507,16 @@ def _prepare_run(cfg: DuetConfig) -> tuple[_RunContext, Optional[dict]]:
4351
4507
  return context, None
4352
4508
 
4353
4509
 
4510
+ def _print_worktree_notice(cfg: DuetConfig, wt_path: Optional[pathlib.Path],
4511
+ wt_branch: Optional[str]) -> None:
4512
+ if wt_path:
4513
+ print(f"[duet] worktree left intact at {wt_path} (branch {wt_branch}).\n"
4514
+ f" merge: git -C {cfg.cwd} merge {wt_branch}\n"
4515
+ f" review: git -C {wt_path} diff HEAD\n"
4516
+ f" drop: git -C {cfg.cwd} worktree remove {wt_path} && "
4517
+ f"git -C {cfg.cwd} branch -D {wt_branch}")
4518
+
4519
+
4354
4520
  def run_duet(cfg: DuetConfig) -> dict:
4355
4521
  global RECAP_MODE
4356
4522
  RECAP_MODE = cfg.recap
@@ -4470,12 +4636,7 @@ def run_duet(cfg: DuetConfig) -> dict:
4470
4636
  print(f"[duet] recap: {recap_path}")
4471
4637
  print(f"[duet] resumable session ids — "
4472
4638
  + ", ".join(f"{a.name}={a.session_id}" for a in cfg.agents if a.session_id))
4473
- if wt_path:
4474
- print(f"[duet] worktree left intact at {wt_path} (branch {wt_branch}).\n"
4475
- f" merge: git -C {cfg.cwd} merge {wt_branch}\n"
4476
- f" review: git -C {wt_path} diff HEAD\n"
4477
- f" drop: git -C {cfg.cwd} worktree remove {wt_path} && "
4478
- f"git -C {cfg.cwd} branch -D {wt_branch}")
4639
+ _print_worktree_notice(cfg, wt_path, wt_branch)
4479
4640
  return state
4480
4641
 
4481
4642
 
@@ -7982,14 +8143,9 @@ def _validate_ready_config(cfg: DuetConfig,
7982
8143
  def _warn_missing_backends(cfg: DuetConfig) -> None:
7983
8144
  if cfg.dry_run:
7984
8145
  return
7985
- for backend in {a.backend for a in cfg.agents}:
8146
+ # The ZCode launch check reports the real ZCode error.
8147
+ for backend in {a.backend for a in cfg.agents} - {"zcode"}:
7986
8148
  binary = backend_adapter(backend).binary
7987
- if backend == "zcode":
7988
- try:
7989
- _zcode_command()
7990
- continue
7991
- except ValueError:
7992
- pass
7993
8149
  if shutil.which(binary) is None:
7994
8150
  print(
7995
8151
  f"[duet] WARNING: '{binary}' not on PATH — this run will fail. "
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: duet-cli
3
- Version: 0.2.17
3
+ Version: 0.2.18
4
4
  Summary: Two CLI agents in conversation. One Python file. Stdlib only.
5
5
  Author: Volkan Altan
6
6
  License-Expression: MIT