duet-cli 0.2.6__tar.gz → 0.2.7__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: duet-cli
3
- Version: 0.2.6
3
+ Version: 0.2.7
4
4
  Summary: Two CLI agents in conversation. One Python file. Stdlib only.
5
5
  Author: Volkan Altan
6
6
  License-Expression: MIT
@@ -166,6 +166,8 @@ duet --reasoning high --codex-fast \
166
166
  --task "Fix the issue" --cwd ~/workspace/project
167
167
  ```
168
168
 
169
+ Use `--no-codex-fast` to override a config or continued run that enabled it.
170
+
169
171
  **Verify gate** — a convergence proposal only counts if `make test` exits 0;
170
172
  any failure feeds back into the next turn:
171
173
 
@@ -145,6 +145,8 @@ duet --reasoning high --codex-fast \
145
145
  --task "Fix the issue" --cwd ~/workspace/project
146
146
  ```
147
147
 
148
+ Use `--no-codex-fast` to override a config or continued run that enabled it.
149
+
148
150
  **Verify gate** — a convergence proposal only counts if `make test` exits 0;
149
151
  any failure feeds back into the next turn:
150
152
 
@@ -58,16 +58,22 @@ import sys
58
58
  import textwrap
59
59
  import threading
60
60
  import time
61
+ import traceback
61
62
  from typing import Callable, Optional
62
63
 
63
64
  # ---------- defaults ----------
64
65
 
65
66
  DEFAULT_SENTINEL = "<<<LGTM>>>"
67
+ DEFAULT_SANDBOX = "workspace-write"
68
+ DEFAULT_PERMISSION_MODE = "acceptEdits"
69
+ SAFE_CONTINUE_SANDBOXES = {"read-only", DEFAULT_SANDBOX}
70
+ SAFE_CONTINUE_PERMISSION_MODES = {"default", DEFAULT_PERMISSION_MODE, "plan"}
66
71
  DEFAULT_TURNS = 2
67
72
  DEFAULT_TIMEOUT = 60 * 15
68
73
  TASK_MAX_CHARS = 512 * 1024
69
74
  CONVERGENCE_RATIONALE_MIN_CHARS = 20
70
75
  VERIFY_OUTPUT_TAIL_CHARS = 4000
76
+ AGENT_ERROR_TRANSCRIPT_MAX_CHARS = 12_000
71
77
  VERIFY_LIVE_PREFIX = " │ [verify] "
72
78
  WORKTREE_FOR_CHOICES = {"lead", "partner"}
73
79
  FINISHED_CONVERGED = "converged"
@@ -317,8 +323,8 @@ class DuetConfig:
317
323
  sentinel: str = DEFAULT_SENTINEL
318
324
  per_turn_timeout: int = DEFAULT_TIMEOUT
319
325
  runs_dir: pathlib.Path = pathlib.Path("runs")
320
- sandbox: str = "workspace-write" # codex
321
- permission_mode: str = "acceptEdits" # claude/gemini
326
+ sandbox: str = DEFAULT_SANDBOX # codex
327
+ permission_mode: str = DEFAULT_PERMISSION_MODE # claude/gemini
322
328
  dry_run: bool = False
323
329
  recap: bool = False
324
330
  verify_cmd: Optional[str] = None # shell command that must pass before
@@ -514,7 +520,8 @@ def is_git_repo(path: pathlib.Path) -> bool:
514
520
  try:
515
521
  r = subprocess.run(
516
522
  ["git", "-C", str(path), "rev-parse", "--is-inside-work-tree"],
517
- capture_output=True, text=True, timeout=5,
523
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
524
+ timeout=5,
518
525
  )
519
526
  return r.returncode == 0 and r.stdout.strip() == "true"
520
527
  except (FileNotFoundError, subprocess.TimeoutExpired):
@@ -540,6 +547,8 @@ def setup_worktree(repo_path: pathlib.Path, branch_name: str,
540
547
  stdout=subprocess.PIPE,
541
548
  stderr=subprocess.PIPE,
542
549
  text=True,
550
+ encoding="utf-8",
551
+ errors="replace",
543
552
  start_new_session=True,
544
553
  )
545
554
  except FileNotFoundError:
@@ -568,15 +577,18 @@ def git_diff_summary(wt_path: pathlib.Path, max_chars: int = 8000) -> str:
568
577
  try:
569
578
  status = subprocess.run(
570
579
  ["git", "-C", str(wt_path), "status", "--short"],
571
- capture_output=True, text=True, timeout=10,
580
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
581
+ timeout=10,
572
582
  ).stdout.rstrip()
573
583
  diff = subprocess.run(
574
584
  ["git", "-C", str(wt_path), "diff", "HEAD", "--stat"],
575
- capture_output=True, text=True, timeout=10,
585
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
586
+ timeout=10,
576
587
  ).stdout.rstrip()
577
588
  full = subprocess.run(
578
589
  ["git", "-C", str(wt_path), "diff", "HEAD"],
579
- capture_output=True, text=True, timeout=10,
590
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
591
+ timeout=10,
580
592
  ).stdout
581
593
  if len(full) > max_chars:
582
594
  full = full[:max_chars] + f"\n…[truncated, {len(full)-max_chars} more chars]"
@@ -837,7 +849,8 @@ def _run(cmd: list[str], *, cwd: pathlib.Path, stdin: Optional[str], timeout: in
837
849
  proc = subprocess.Popen(
838
850
  cmd, cwd=str(cwd),
839
851
  stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
840
- text=True, bufsize=1, # line-buffered
852
+ text=True, encoding="utf-8", errors="replace",
853
+ bufsize=1, # line-buffered
841
854
  start_new_session=True,
842
855
  )
843
856
  except FileNotFoundError:
@@ -848,7 +861,7 @@ def _run(cmd: list[str], *, cwd: pathlib.Path, stdin: Optional[str], timeout: in
848
861
  pid_file_path.parent.mkdir(parents=True, exist_ok=True)
849
862
  # Write atomically so a poller never reads a half-written PID.
850
863
  tmp = pid_file_path.with_name(pid_file_path.name + ".tmp")
851
- tmp.write_text(f"{proc.pid}\n")
864
+ tmp.write_text(f"{proc.pid}\n", encoding="utf-8")
852
865
  os.replace(tmp, pid_file_path)
853
866
  except OSError as e:
854
867
  print(f"[duet] warn: pid file write failed ({pid_file_path}): {e}",
@@ -1158,31 +1171,46 @@ def call_claude(agent: Agent, system_prompt: str, message: str,
1158
1171
  # Codex's `codex exec` prints a line like `session id: 019e12ad-0b1b-7732-bd7b-6acbbd04ab46`
1159
1172
  # to stderr near startup; modern builds also re-emit it on resume. We pin to that
1160
1173
  # UUID for subsequent resumes so duet doesn't depend on `--last`'s cwd-keyed
1161
- # lookup. Anchored to "session id" to avoid false-positives on stray UUIDs in
1162
- # tracebacks or path strings; case-insensitive because the label has varied.
1174
+ # lookup. Match only line-start session labels so a stray inline UUID or tool
1175
+ # trace does not become a resume pin; case-insensitive because the label has
1176
+ # varied.
1163
1177
  _CODEX_UUID_PATTERN = (
1164
1178
  r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
1165
1179
  )
1166
1180
  _CODEX_SESSION_ID_RE = re.compile(
1167
- r"session[ _-]?id\s*[:=]\s*(" + _CODEX_UUID_PATTERN + r")",
1168
- re.IGNORECASE,
1181
+ r"^\s*session[ _-]?id\s*[:=]\s*(" + _CODEX_UUID_PATTERN + r")",
1182
+ re.IGNORECASE | re.MULTILINE,
1169
1183
  )
1170
1184
  _CODEX_UUID_RE = re.compile(r"\A" + _CODEX_UUID_PATTERN + r"\Z", re.IGNORECASE)
1171
1185
 
1172
1186
 
1173
1187
  def _parse_codex_session_id(stderr: str) -> Optional[str]:
1174
- """Return the last `session id: <uuid>` UUID found in Codex's stderr.
1188
+ """Return the first line-start `session id: <uuid>` from Codex stderr.
1175
1189
 
1176
- The last match wins so a resume that emits both the inherited id and a
1177
- rotated id (if a future Codex build does that) ends up pinned to the
1178
- rotated one. Returns the UUID lowercased; None if no match. We never parse
1179
- stdout — Codex puts the assistant reply there and a UUID inside the reply
1180
- must not be confused for the harness's session pin.
1190
+ Prefer the startup line over later stderr content from tools. Returns the
1191
+ UUID lowercased; None if no match. We never parse stdout Codex puts the
1192
+ assistant reply there and a UUID inside the reply must not be confused for
1193
+ the harness's session pin.
1181
1194
  """
1182
1195
  if not stderr:
1183
1196
  return None
1184
- matches = _CODEX_SESSION_ID_RE.findall(stderr)
1185
- return matches[-1].lower() if matches else None
1197
+ match = _CODEX_SESSION_ID_RE.search(stderr)
1198
+ return match.group(1).lower() if match else None
1199
+
1200
+
1201
+ def _resolve_codex_session_pin(agent: Agent, parsed_sid: Optional[str]) -> Optional[str]:
1202
+ """Merge a parsed Codex session id with the agent's existing resume pin."""
1203
+ if not parsed_sid:
1204
+ return agent.session_id
1205
+ if agent.session_id and _CODEX_UUID_RE.match(agent.session_id):
1206
+ if parsed_sid != agent.session_id.lower():
1207
+ print(
1208
+ "[duet] WARNING: codex stderr reported a different session id "
1209
+ f"({parsed_sid}); keeping established pin {agent.session_id}",
1210
+ file=sys.stderr,
1211
+ )
1212
+ return agent.session_id
1213
+ return parsed_sid
1186
1214
 
1187
1215
 
1188
1216
  def call_codex(agent: Agent, system_prompt: str, message: str,
@@ -1235,10 +1263,10 @@ def call_codex(agent: Agent, system_prompt: str, message: str,
1235
1263
  # `["--full-auto"]` or `["--yolo"]`) and config overrides (`-c …`).
1236
1264
  #
1237
1265
  # IMPORTANT: `codex exec resume` accepts a SUBSET of `codex exec`'s
1238
- # flags. In particular, `--sandbox` and `--cd` are exec-only they
1239
- # carry over from the resumed session and codex's clap parser rejects
1240
- # them on resume with "unexpected argument '--sandbox' found". So we
1241
- # split: exec_only_opts are passed only on the first call.
1266
+ # flags. In particular, `--sandbox` and `--cd` are exec-only, and codex's
1267
+ # clap parser rejects them on resume with "unexpected argument '--sandbox'
1268
+ # found". Resume does accept config overrides, so reassert the sandbox via
1269
+ # `sandbox_mode` instead of relying on the resumed invocation's defaults.
1242
1270
  shared_opts = ["--skip-git-repo-check"]
1243
1271
  if agent.model:
1244
1272
  shared_opts += ["--model", agent.model]
@@ -1250,9 +1278,16 @@ def call_codex(agent: Agent, system_prompt: str, message: str,
1250
1278
  cmd = ["codex", "exec", *options, full_prompt]
1251
1279
  else:
1252
1280
  # cwd is set via subprocess.Popen(cwd=…) so codex inherits the right
1253
- # directory regardless of how we resume. `--sandbox` and `--cd` are
1254
- # exec-only; sandbox carries over from the resumed session.
1255
- options = [*shared_opts, *reasoning_args, *agent.extra_args]
1281
+ # directory regardless of how we resume. Keep the supported config
1282
+ # override before the positional prompt so saved or explicitly narrowed
1283
+ # continuation policies are enforced on the resumed turn.
1284
+ resume_sandbox_opts = ["-c", f"sandbox_mode={json.dumps(sandbox)}"]
1285
+ options = [
1286
+ *shared_opts,
1287
+ *reasoning_args,
1288
+ *agent.extra_args,
1289
+ *resume_sandbox_opts,
1290
+ ]
1256
1291
  if _CODEX_UUID_RE.match(agent.session_id):
1257
1292
  # Pin to the UUID we parsed from a prior turn's stderr. This is
1258
1293
  # robust to parallel codex sessions sharing the cwd because
@@ -1286,7 +1321,8 @@ def call_codex(agent: Agent, system_prompt: str, message: str,
1286
1321
  # were already carrying; finally fall back to the "codex-current"
1287
1322
  # sentinel so the next turn at least knows a prior turn happened.
1288
1323
  parsed_sid = _parse_codex_session_id(err)
1289
- return out.rstrip(), parsed_sid or agent.session_id or "codex-current"
1324
+ session_pin = _resolve_codex_session_pin(agent, parsed_sid)
1325
+ return out.rstrip(), session_pin or "codex-current"
1290
1326
 
1291
1327
 
1292
1328
  def _parse_gemini_session_id(stdout: str) -> Optional[str]:
@@ -1803,6 +1839,50 @@ def call_agent(agent: Agent, message: str, cfg: DuetConfig, first_turn_for_agent
1803
1839
  return text
1804
1840
 
1805
1841
 
1842
+ def _bounded_agent_error_excerpt(
1843
+ text: str,
1844
+ max_chars: int = AGENT_ERROR_TRANSCRIPT_MAX_CHARS,
1845
+ ) -> str:
1846
+ """Keep the useful edges of an error without bloating the transcript."""
1847
+ if max_chars <= 0:
1848
+ return ""
1849
+ if len(text) <= max_chars:
1850
+ return text
1851
+
1852
+ omitted = len(text)
1853
+ while True:
1854
+ marker = (
1855
+ f"\n\n[duet] error text truncated: {omitted} characters omitted; "
1856
+ "full subprocess stderr remains in the log below.\n\n"
1857
+ )
1858
+ excerpt_chars = max_chars - len(marker)
1859
+ if excerpt_chars <= 0:
1860
+ fallback = (
1861
+ f"[duet] error text truncated: {len(text)} characters omitted"
1862
+ )
1863
+ return fallback[:max_chars]
1864
+ head_chars = (excerpt_chars + 1) // 2
1865
+ tail_chars = excerpt_chars // 2
1866
+ new_omitted = len(text) - head_chars - tail_chars
1867
+ if new_omitted == omitted:
1868
+ tail = text[-tail_chars:] if tail_chars else ""
1869
+ return text[:head_chars] + marker + tail
1870
+ omitted = new_omitted
1871
+
1872
+
1873
+ def format_agent_error_for_transcript(
1874
+ exc: Exception,
1875
+ stderr_log_path: pathlib.Path,
1876
+ max_chars: int = AGENT_ERROR_TRANSCRIPT_MAX_CHARS,
1877
+ ) -> str:
1878
+ """Format bounded error context while pointing at the complete stderr."""
1879
+ excerpt = _bounded_agent_error_excerpt(str(exc), max_chars)
1880
+ return "\n".join([
1881
+ f"[duet] error: {excerpt}",
1882
+ f"[duet] stderr log: {stderr_log_path}",
1883
+ ])
1884
+
1885
+
1806
1886
  def _agent_failure_block(reason: str, exc: Exception, turn_label: str,
1807
1887
  agent: Agent, run_dir: pathlib.Path) -> str:
1808
1888
  kind = "TIMEOUT" if reason == FINISHED_TIMEOUT else "AGENT ERROR"
@@ -1811,8 +1891,7 @@ def _agent_failure_block(reason: str, exc: Exception, turn_label: str,
1811
1891
  f"[duet] {kind}: turn {turn_label} failed for "
1812
1892
  f"{agent.name} ({agent.backend}/{agent.role})",
1813
1893
  f"[duet] finished_reason: {reason}",
1814
- f"[duet] error: {exc}",
1815
- f"[duet] stderr log: {log_path}",
1894
+ format_agent_error_for_transcript(exc, log_path),
1816
1895
  ])
1817
1896
 
1818
1897
  # ---------- loop ----------
@@ -2078,7 +2157,8 @@ def _setup_run_worktree(
2078
2157
  try:
2079
2158
  r = subprocess.run(
2080
2159
  ["git", "-C", str(existing), "rev-parse", "--abbrev-ref", "HEAD"],
2081
- capture_output=True, text=True, timeout=5,
2160
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
2161
+ timeout=5,
2082
2162
  )
2083
2163
  wt_branch = r.stdout.strip() if r.returncode == 0 else None
2084
2164
  except Exception:
@@ -2135,11 +2215,18 @@ def _build_run_state(cfg: DuetConfig, *, turns_used: int, history: list,
2135
2215
  "history": history,
2136
2216
  "finished_reason": finished_reason,
2137
2217
  "transcript_path": str(transcript_path),
2218
+ "sentinel": cfg.sentinel,
2219
+ "sandbox": cfg.sandbox,
2220
+ "permission_mode": cfg.permission_mode,
2221
+ "per_turn_timeout": cfg.per_turn_timeout,
2138
2222
  "verify_cmd": cfg.verify_cmd,
2139
2223
  "last_verify": last_verify,
2140
2224
  "worktree": str(wt_path) if wt_path else None,
2141
2225
  "worktree_branch": wt_branch,
2142
2226
  "worktree_for": cfg.worktree_for,
2227
+ "add_dirs": [str(d) for d in cfg.add_dirs],
2228
+ "reasoning": cfg.reasoning,
2229
+ "codex_fast": cfg.codex_fast,
2143
2230
  "continue_from": cfg.continue_from,
2144
2231
  "duet_pid": os.getpid(),
2145
2232
  }
@@ -2702,6 +2789,121 @@ def normalize_verify_cmd(value, parser: argparse.ArgumentParser) -> Optional[str
2702
2789
  return cmd
2703
2790
 
2704
2791
 
2792
+ def _state_int_or_arg(arg_value: Optional[int],
2793
+ state: dict,
2794
+ key: str,
2795
+ default: int,
2796
+ parser: argparse.ArgumentParser,
2797
+ option_name: str) -> int:
2798
+ if arg_value is not None:
2799
+ return arg_value
2800
+ value = state.get(key, default)
2801
+ if isinstance(value, bool):
2802
+ parser.error(f"{option_name}: state.json field {key!r} must be an integer")
2803
+ if isinstance(value, float) and not value.is_integer():
2804
+ parser.error(f"{option_name}: state.json field {key!r} must be an integer")
2805
+ try:
2806
+ return int(value)
2807
+ except (TypeError, ValueError):
2808
+ parser.error(f"{option_name}: state.json field {key!r} must be an integer")
2809
+ raise AssertionError("parser.error should have exited")
2810
+
2811
+
2812
+ def _state_str_or_arg(arg_value: Optional[str],
2813
+ state: dict,
2814
+ key: str,
2815
+ default: str,
2816
+ parser: argparse.ArgumentParser,
2817
+ option_name: str) -> str:
2818
+ if arg_value is not None:
2819
+ return arg_value
2820
+ value = state.get(key, default)
2821
+ if not isinstance(value, str):
2822
+ parser.error(f"{option_name}: state.json field {key!r} must be a string")
2823
+ return value
2824
+
2825
+
2826
+ def _state_list_or_arg(arg_value: Optional[list[str]],
2827
+ state: dict,
2828
+ key: str,
2829
+ parser: argparse.ArgumentParser,
2830
+ option_name: str) -> list[str]:
2831
+ if arg_value is not None:
2832
+ return arg_value
2833
+ value = state.get(key, [])
2834
+ if not isinstance(value, list):
2835
+ parser.error(f"{option_name}: state.json field {key!r} must be a list")
2836
+ return [str(item) for item in value]
2837
+
2838
+
2839
+ def _state_extra_args(agents: list[Agent]) -> list[tuple[str, list[str]]]:
2840
+ return [(a.name, list(a.extra_args)) for a in agents if a.extra_args]
2841
+
2842
+
2843
+ def _state_authority_replays(args: argparse.Namespace,
2844
+ sandbox: str,
2845
+ permission_mode: str,
2846
+ add_dirs: list[str]) -> list[tuple[str, object]]:
2847
+ """Return state-sourced settings that can widen a continued agent's authority."""
2848
+ restored: list[tuple[str, object]] = []
2849
+ if args.sandbox is None and sandbox not in SAFE_CONTINUE_SANDBOXES:
2850
+ restored.append(("sandbox", sandbox))
2851
+ if (args.permission_mode is None
2852
+ and permission_mode not in SAFE_CONTINUE_PERMISSION_MODES):
2853
+ restored.append(("permission_mode", permission_mode))
2854
+ if args.add_dirs is None and add_dirs:
2855
+ restored.append(("add_dirs", add_dirs))
2856
+ return restored
2857
+
2858
+
2859
+ def _guard_continue_state_replay(state: dict,
2860
+ agents: list[Agent],
2861
+ args: argparse.Namespace,
2862
+ parser: argparse.ArgumentParser,
2863
+ *,
2864
+ sandbox: str,
2865
+ permission_mode: str,
2866
+ add_dirs: list[str]) -> None:
2867
+ """Require trust for executable or authority-widening state.json fields."""
2868
+ restored_verify = args.verify_cmd is None and bool(state.get("verify_cmd"))
2869
+ restored_extra_args = _state_extra_args(agents)
2870
+ authority_replays = _state_authority_replays(
2871
+ args, sandbox, permission_mode, add_dirs
2872
+ )
2873
+ protected_fields: list[str] = []
2874
+ if restored_verify:
2875
+ protected_fields.append("verify_cmd")
2876
+ if restored_extra_args:
2877
+ protected_fields.append("agent extra_args")
2878
+ protected_fields.extend(name for name, _ in authority_replays)
2879
+ if not protected_fields:
2880
+ return
2881
+ if not args.trust_state and not args.dry_run:
2882
+ parser.error(
2883
+ "--continue: refusing to replay state-sourced "
2884
+ + ", ".join(protected_fields)
2885
+ + "; pass --trust-state after inspecting state.json, or provide "
2886
+ "fresh CLI overrides for the saved settings"
2887
+ )
2888
+ if args.trust_state:
2889
+ if restored_verify:
2890
+ print(
2891
+ f"[duet] trusting verify_cmd from state.json: {state.get('verify_cmd')}",
2892
+ file=sys.stderr,
2893
+ )
2894
+ for name, extra_args in restored_extra_args:
2895
+ print(
2896
+ f"[duet] trusting extra_args from state.json for {name}: "
2897
+ f"{shlex.join(extra_args)}",
2898
+ file=sys.stderr,
2899
+ )
2900
+ for name, value in authority_replays:
2901
+ print(
2902
+ f"[duet] trusting {name} from state.json: {value!r}",
2903
+ file=sys.stderr,
2904
+ )
2905
+
2906
+
2705
2907
  def _slot_name(backend: str, idx: int) -> str:
2706
2908
  slot = "lead" if idx == 0 else "partner"
2707
2909
  return f"{backend}-{slot}"
@@ -2831,7 +3033,7 @@ def apply_resume_overrides(
2831
3033
 
2832
3034
 
2833
3035
  def load_yaml_or_json(path: pathlib.Path) -> dict:
2834
- text = path.read_text()
3036
+ text = path.read_text(encoding="utf-8")
2835
3037
  if path.suffix in {".yaml", ".yml"}:
2836
3038
  try:
2837
3039
  import yaml # type: ignore
@@ -2989,6 +3191,31 @@ def _pid_alive(pid: int) -> bool:
2989
3191
  return True
2990
3192
 
2991
3193
 
3194
+ def _coerce_pid(value: object) -> Optional[int]:
3195
+ if isinstance(value, bool):
3196
+ return None
3197
+ if isinstance(value, float) and not value.is_integer():
3198
+ return None
3199
+ try:
3200
+ pid = int(value) # type: ignore[arg-type]
3201
+ except (TypeError, ValueError):
3202
+ return None
3203
+ return pid if pid > 0 else None
3204
+
3205
+
3206
+ def _pid_file_snapshot(pid_file: pathlib.Path) -> Optional[tuple[Optional[int], dt.datetime]]:
3207
+ """Read a transient pid file; return None if it disappears mid-status."""
3208
+ try:
3209
+ text = pid_file.read_text(encoding="utf-8").strip()
3210
+ stat = pid_file.stat()
3211
+ except FileNotFoundError:
3212
+ return None
3213
+ except OSError as e:
3214
+ print(f"[duet] unable to read pid file {pid_file}: {e}", file=sys.stderr)
3215
+ return None
3216
+ return _coerce_pid(text), dt.datetime.fromtimestamp(stat.st_mtime)
3217
+
3218
+
2992
3219
  def _proc_cmdline(pid: int) -> Optional[str]:
2993
3220
  """Best-effort read of a PID's full cmdline. Returns None on any failure.
2994
3221
 
@@ -3004,8 +3231,11 @@ def _proc_cmdline(pid: int) -> Optional[str]:
3004
3231
  return None
3005
3232
  # macOS / BSD: shell out to ps. Cheap, ~5ms.
3006
3233
  try:
3007
- r = subprocess.run(["ps", "-o", "command=", "-p", str(pid)],
3008
- capture_output=True, text=True, timeout=2)
3234
+ r = subprocess.run(
3235
+ ["ps", "-o", "command=", "-p", str(pid)],
3236
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
3237
+ timeout=2,
3238
+ )
3009
3239
  if r.returncode == 0 and r.stdout.strip():
3010
3240
  return r.stdout.strip()
3011
3241
  except (subprocess.TimeoutExpired, OSError):
@@ -3050,7 +3280,7 @@ def print_run_status(arg: str) -> int:
3050
3280
  state: dict = {}
3051
3281
  if state_path.exists():
3052
3282
  try:
3053
- state = json.loads(state_path.read_text())
3283
+ state = json.loads(state_path.read_text(encoding="utf-8"))
3054
3284
  except json.JSONDecodeError as e:
3055
3285
  print(f"[duet] state.json malformed: {e}", file=sys.stderr)
3056
3286
  return 3
@@ -3069,31 +3299,33 @@ def print_run_status(arg: str) -> int:
3069
3299
  pid_files = sorted(run_dir.glob("turn-*.pid"))
3070
3300
  if pid_files:
3071
3301
  pid_file = pid_files[-1]
3072
- try:
3073
- pid = int(pid_file.read_text().strip())
3074
- except (OSError, ValueError):
3075
- pid = None
3076
- # Filename: turn-<label>-<agent>.pid
3077
- stem = pid_file.stem # turn-02-claude-planner
3078
- started_at = dt.datetime.fromtimestamp(pid_file.stat().st_mtime)
3079
- elapsed = (dt.datetime.now() - started_at).total_seconds()
3080
- alive = _pid_alive(pid) if pid is not None else False
3081
- print(f" in-flight turn: {stem}")
3082
- print(f" pid: {pid} (alive: {alive})")
3083
- print(f" started: {started_at.isoformat(timespec='seconds')} "
3084
- f"({int(elapsed)}s ago)")
3085
- # Heartbeat from the matching stderr log
3086
- log = run_dir / f"{stem}.stderr.log"
3087
- if log.exists():
3088
- log_age = (dt.datetime.now()
3089
- - dt.datetime.fromtimestamp(log.stat().st_mtime)).total_seconds()
3090
- print(f" last stderr: {int(log_age)}s ago "
3091
- f"({log.stat().st_size} bytes)")
3092
- if not alive:
3093
- print(" ⚠ pid file present but process is gone — turn likely "
3094
- "crashed or was killed without cleanup")
3095
- return 2
3096
- return 1
3302
+ snapshot = _pid_file_snapshot(pid_file)
3303
+ if snapshot is not None:
3304
+ pid, started_at = snapshot
3305
+ # Filename: turn-<label>-<agent>.pid
3306
+ stem = pid_file.stem # turn-02-claude-planner
3307
+ elapsed = (dt.datetime.now() - started_at).total_seconds()
3308
+ alive = _pid_alive(pid) if pid is not None else False
3309
+ print(f" in-flight turn: {stem}")
3310
+ print(f" pid: {pid} (alive: {alive})")
3311
+ print(f" started: {started_at.isoformat(timespec='seconds')} "
3312
+ f"({int(elapsed)}s ago)")
3313
+ # Heartbeat from the matching stderr log
3314
+ log = run_dir / f"{stem}.stderr.log"
3315
+ try:
3316
+ log_stat = log.stat()
3317
+ except OSError:
3318
+ log_stat = None
3319
+ if log_stat is not None:
3320
+ log_age = (dt.datetime.now()
3321
+ - dt.datetime.fromtimestamp(log_stat.st_mtime)).total_seconds()
3322
+ print(f" last stderr: {int(log_age)}s ago "
3323
+ f"({log_stat.st_size} bytes)")
3324
+ if not alive:
3325
+ print(" ⚠ pid file present but process is gone — turn likely "
3326
+ "crashed or was killed without cleanup")
3327
+ return 2
3328
+ return 1
3097
3329
  # No pid files. Either run hasn't started, has finished, or is between
3098
3330
  # turns (in particular, sitting at the post-loop `force>` prompt).
3099
3331
  if finished:
@@ -3104,7 +3336,12 @@ def print_run_status(arg: str) -> int:
3104
3336
  # duet_pid recorded in state.json.
3105
3337
  duet_pid = state.get("duet_pid")
3106
3338
  if duet_pid is not None:
3107
- if _is_duet_process(int(duet_pid)):
3339
+ pid = _coerce_pid(duet_pid)
3340
+ if pid is None:
3341
+ print(f" ⚠ invalid duet_pid {duet_pid!r}; no valid liveness "
3342
+ "proof recorded")
3343
+ return 2
3344
+ if _is_duet_process(pid):
3108
3345
  print(f" state: between turns / awaiting force> prompt")
3109
3346
  print(f" duet pid: {duet_pid} (alive)")
3110
3347
  history = state.get("history") or []
@@ -3128,6 +3365,18 @@ def print_run_status(arg: str) -> int:
3128
3365
  return 2
3129
3366
 
3130
3367
 
3368
+ def print_run_status_safe(arg: str) -> int:
3369
+ try:
3370
+ return print_run_status(arg)
3371
+ except Exception as e:
3372
+ print(
3373
+ f"[duet] status failed ({type(e).__name__}): {e}",
3374
+ file=sys.stderr,
3375
+ )
3376
+ traceback.print_exc(file=sys.stderr)
3377
+ return 3
3378
+
3379
+
3131
3380
  # ---------- run-list (`duet --list [PATH]`) ----------
3132
3381
 
3133
3382
  # Status glyphs — same vocabulary as print_run_status, packed for table cols.
@@ -3214,7 +3463,7 @@ def _load_run_state(run_dir: pathlib.Path,
3214
3463
  if not state_path.is_file():
3215
3464
  parser.error(f"{option_name}: missing state.json in {run_dir}")
3216
3465
  try:
3217
- return json.loads(state_path.read_text())
3466
+ return json.loads(state_path.read_text(encoding="utf-8"))
3218
3467
  except json.JSONDecodeError as e:
3219
3468
  parser.error(f"{option_name}: state.json malformed: {e}")
3220
3469
  except OSError as e:
@@ -3348,6 +3597,28 @@ def build_continue_config(run_arg: str,
3348
3597
  parser.error(f"--continue: no such run dir or id: {run_arg}")
3349
3598
  state = _load_run_state(run_dir, parser, "--continue")
3350
3599
  agents = _agents_from_state(state, parser, "--continue")
3600
+ sentinel = _state_str_or_arg(
3601
+ args.sentinel, state, "sentinel", DEFAULT_SENTINEL, parser, "--continue"
3602
+ )
3603
+ sandbox = _state_str_or_arg(
3604
+ args.sandbox, state, "sandbox", DEFAULT_SANDBOX, parser, "--continue"
3605
+ )
3606
+ permission_mode = _state_str_or_arg(
3607
+ args.permission_mode, state, "permission_mode", DEFAULT_PERMISSION_MODE,
3608
+ parser, "--continue",
3609
+ )
3610
+ add_dirs = _state_list_or_arg(
3611
+ args.add_dirs, state, "add_dirs", parser, "--continue"
3612
+ )
3613
+ _guard_continue_state_replay(
3614
+ state,
3615
+ agents,
3616
+ args,
3617
+ parser,
3618
+ sandbox=sandbox,
3619
+ permission_mode=permission_mode,
3620
+ add_dirs=add_dirs,
3621
+ )
3351
3622
  # Older runs (or runs that crashed before the first state.json roll) may
3352
3623
  # have Codex agents that already spoke but have no saved session_id. Without
3353
3624
  # a marker, run_duet would treat the next turn as a fresh `codex exec` and
@@ -3363,7 +3634,10 @@ def build_continue_config(run_arg: str,
3363
3634
  and agent.name in codex_speakers):
3364
3635
  agent.session_id = "codex-current"
3365
3636
  cwd = pathlib.Path(state.get("cwd") or ".").expanduser().resolve()
3366
- timeout = args.timeout
3637
+ timeout = _state_int_or_arg(
3638
+ args.timeout, state, "per_turn_timeout", DEFAULT_TIMEOUT,
3639
+ parser, "--continue",
3640
+ )
3367
3641
  user_note = _continue_note_from_args(args, cwd, timeout, parser, stdin_cache)
3368
3642
  next_idx = _next_speaker_idx_from_state(agents, state)
3369
3643
 
@@ -3385,11 +3659,11 @@ def build_continue_config(run_arg: str,
3385
3659
  task=state.get("task"),
3386
3660
  kickoff=kickoff,
3387
3661
  max_turns=args.turns,
3388
- sentinel=args.sentinel,
3662
+ sentinel=sentinel,
3389
3663
  per_turn_timeout=timeout,
3390
3664
  runs_dir=runs_dir,
3391
- sandbox=args.sandbox,
3392
- permission_mode=args.permission_mode,
3665
+ sandbox=sandbox,
3666
+ permission_mode=permission_mode,
3393
3667
  dry_run=args.dry_run,
3394
3668
  recap=args.recap or bool(state.get("recap_path")),
3395
3669
  verify_cmd=normalize_verify_cmd(
@@ -3399,9 +3673,15 @@ def build_continue_config(run_arg: str,
3399
3673
  worktree=False,
3400
3674
  worktree_for=worktree_for,
3401
3675
  worktree_path=worktree_path,
3402
- add_dirs=[pathlib.Path(d).expanduser().resolve() for d in args.add_dirs],
3403
- reasoning=args.reasoning,
3404
- codex_fast=bool(args.codex_fast),
3676
+ add_dirs=[
3677
+ pathlib.Path(d).expanduser().resolve()
3678
+ for d in add_dirs
3679
+ ],
3680
+ reasoning=args.reasoning if args.reasoning is not None else state.get("reasoning"),
3681
+ codex_fast=bool(
3682
+ args.codex_fast
3683
+ if args.codex_fast is not None else state.get("codex_fast", False)
3684
+ ),
3405
3685
  start_speaker_idx=next_idx,
3406
3686
  continue_from=str(run_dir),
3407
3687
  )
@@ -3434,7 +3714,7 @@ def _classify_run(run_dir: pathlib.Path) -> tuple[str, str, dict]:
3434
3714
  if not state_path.is_file():
3435
3715
  return ("❓", "no state.json", {})
3436
3716
  try:
3437
- state = json.loads(state_path.read_text())
3717
+ state = json.loads(state_path.read_text(encoding="utf-8"))
3438
3718
  except json.JSONDecodeError:
3439
3719
  return ("❓", "malformed state", {})
3440
3720
  finished = state.get("finished_reason")
@@ -3445,9 +3725,12 @@ def _classify_run(run_dir: pathlib.Path) -> tuple[str, str, dict]:
3445
3725
  if list(run_dir.glob("turn-*.pid")):
3446
3726
  return ("🟢", "in-flight", state)
3447
3727
  pid = state.get("duet_pid")
3448
- if pid is not None and _is_duet_process(int(pid)):
3449
- return ("🟢", "between turns", state)
3450
3728
  if pid is not None:
3729
+ coerced = _coerce_pid(pid)
3730
+ if coerced is None:
3731
+ return ("⚠", "invalid pid", state)
3732
+ if _is_duet_process(coerced):
3733
+ return ("🟢", "between turns", state)
3451
3734
  return ("⚠", "duet died", state)
3452
3735
  return ("⚠", "stuck (no pid)", state)
3453
3736
 
@@ -3557,18 +3840,19 @@ def _build_arg_parser() -> argparse.ArgumentParser:
3557
3840
  help="model name to pass to the partner agent's backend via --model")
3558
3841
  ap.add_argument("--cwd", default=".", help="working dir for both agents")
3559
3842
  ap.add_argument("--turns", type=int, default=DEFAULT_TURNS, help=f"max turns (default {DEFAULT_TURNS})")
3560
- ap.add_argument("--sentinel", default=DEFAULT_SENTINEL,
3843
+ ap.add_argument("--sentinel", default=None,
3561
3844
  help="convergence sentinel; requires an LGTM rationale and "
3562
3845
  "back-to-back proposals from both agents")
3563
- ap.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT, help="per-turn timeout seconds")
3846
+ ap.add_argument("--timeout", type=int, default=None,
3847
+ help=f"per-turn timeout seconds (default {DEFAULT_TIMEOUT})")
3564
3848
  ap.add_argument("--verify-cmd", metavar="CMD", default=None,
3565
3849
  help="shell command that must exit 0 before a convergence "
3566
3850
  "proposal can count. Runs only for valid LGTM+rationale "
3567
3851
  "proposals; YAML key: `verify_cmd:`.")
3568
3852
  ap.add_argument("--runs-dir", default=None, help="where to save transcripts")
3569
- ap.add_argument("--sandbox", default="workspace-write",
3853
+ ap.add_argument("--sandbox", default=None,
3570
3854
  help="codex --sandbox: read-only|workspace-write|danger-full-access. No-op for claude/gemini/copilot/opencode.")
3571
- ap.add_argument("--permission-mode", default="acceptEdits",
3855
+ ap.add_argument("--permission-mode", default=None,
3572
3856
  help="claude --permission-mode; gemini maps default/acceptEdits/plan/bypassPermissions to --approval-mode. No-op for copilot/opencode.")
3573
3857
  ap.add_argument("--config", help="optional YAML/JSON config (overrides flags except --resume-*)")
3574
3858
  ap.add_argument("--worktree", action="store_true",
@@ -3586,7 +3870,7 @@ def _build_arg_parser() -> argparse.ArgumentParser:
3586
3870
  "Each run lands at <PATH>/<run_id>/. Default: <runs_dir>/<run_id>/wt/, "
3587
3871
  "which is durable across reboots and OS temp-dir cleaners. "
3588
3872
  "Pass /tmp or $TMPDIR to mimic the pre-fix throwaway behavior.")
3589
- ap.add_argument("--add-dir", action="append", metavar="PATH", default=[],
3873
+ ap.add_argument("--add-dir", action="append", metavar="PATH", default=None,
3590
3874
  dest="add_dirs",
3591
3875
  help="extra path claude/gemini/copilot may access outside cwd. "
3592
3876
  "Repeatable. Claude and Copilot receive --add-dir; "
@@ -3599,14 +3883,24 @@ def _build_arg_parser() -> argparse.ArgumentParser:
3599
3883
  "(minimal → low) and adds high/xhigh/max prompt nudges. "
3600
3884
  "Copilot: passes `--effort <v>` (minimal → none). "
3601
3885
  "Gemini: no effort flag; high/xhigh/max are prompt nudges only.")
3602
- ap.add_argument("--codex-fast", action="store_true", dest="codex_fast",
3603
- help="Codex-only fast mode: pin codex coder turns to "
3604
- "`model_reasoning_effort=low` and "
3605
- "`model_reasoning_summary=concise`, regardless of "
3606
- "--reasoning / per-agent reasoning_effort. Trades "
3607
- "depth for latency on codex coder turns; claude is "
3608
- "unaffected, so `--reasoning high --codex-fast` is "
3609
- "a real and useful combo. YAML key: `codex_fast: true`.")
3886
+ codex_fast_group = ap.add_mutually_exclusive_group()
3887
+ codex_fast_group.add_argument(
3888
+ "--codex-fast", action="store_true", dest="codex_fast",
3889
+ help="Codex-only fast mode: pin codex coder turns to "
3890
+ "`model_reasoning_effort=low` and "
3891
+ "`model_reasoning_summary=concise`, regardless of --reasoning / "
3892
+ "per-agent reasoning_effort. YAML key: `codex_fast: true`.",
3893
+ )
3894
+ codex_fast_group.add_argument(
3895
+ "--no-codex-fast", action="store_false", dest="codex_fast",
3896
+ help="disable codex fast mode, including a value restored by "
3897
+ "--continue or enabled by a YAML/JSON config",
3898
+ )
3899
+ ap.set_defaults(codex_fast=None)
3900
+ ap.add_argument("--trust-state", action="store_true", dest="trust_state",
3901
+ help="with --continue, allow state.json-sourced commands "
3902
+ "and authority-widening sandbox, permission, or "
3903
+ "add-dir settings after inspecting the run directory")
3610
3904
  ap.add_argument("--status", metavar="RUN_DIR_OR_ID", default=None,
3611
3905
  help="don't run a duet — instead print a one-shot health "
3612
3906
  "summary of an existing run and exit. Accepts a path "
@@ -3647,10 +3941,13 @@ def _build_cfg_from_yaml(args: argparse.Namespace, ap: argparse.ArgumentParser,
3647
3941
  """Build a DuetConfig from a --config YAML/JSON file. CLI flags only fill
3648
3942
  seed inputs (task/kickoff) when the file specifies none, and a handful of
3649
3943
  flags (runs_dir, verify_cmd, worktree*, recap, reasoning, codex_fast)
3650
- override or OR with their file values; --resume-* still apply on top."""
3944
+ override or combine with file values; --resume-* still apply on top."""
3651
3945
  raw = load_yaml_or_json(pathlib.Path(args.config))
3652
3946
  cfg_cwd = pathlib.Path(raw.get("cwd", ".")).expanduser().resolve()
3653
- cfg_timeout = int(raw.get("per_turn_timeout", DEFAULT_TIMEOUT))
3947
+ cfg_timeout = int(raw.get(
3948
+ "per_turn_timeout",
3949
+ args.timeout if args.timeout is not None else DEFAULT_TIMEOUT,
3950
+ ))
3654
3951
  raw_task = raw.get("task")
3655
3952
  raw_kickoff = raw.get("kickoff")
3656
3953
  raw_task_from_cmd = raw.get("task_from_cmd")
@@ -3681,11 +3978,21 @@ def _build_cfg_from_yaml(args: argparse.Namespace, ap: argparse.ArgumentParser,
3681
3978
  task=task,
3682
3979
  kickoff=kickoff,
3683
3980
  max_turns=int(raw.get("max_turns", DEFAULT_TURNS)),
3684
- sentinel=raw.get("sentinel", DEFAULT_SENTINEL),
3981
+ sentinel=raw.get(
3982
+ "sentinel",
3983
+ args.sentinel if args.sentinel is not None else DEFAULT_SENTINEL,
3984
+ ),
3685
3985
  per_turn_timeout=cfg_timeout,
3686
3986
  runs_dir=choose_runs_dir(raw_runs_dir, cfg_cwd),
3687
- sandbox=raw.get("sandbox", "workspace-write"),
3688
- permission_mode=raw.get("permission_mode", "acceptEdits"),
3987
+ sandbox=raw.get(
3988
+ "sandbox",
3989
+ args.sandbox if args.sandbox is not None else DEFAULT_SANDBOX,
3990
+ ),
3991
+ permission_mode=raw.get(
3992
+ "permission_mode",
3993
+ args.permission_mode
3994
+ if args.permission_mode is not None else DEFAULT_PERMISSION_MODE,
3995
+ ),
3689
3996
  dry_run=bool(raw.get("dry_run", False)),
3690
3997
  recap=bool(raw.get("recap", False)) or args.recap,
3691
3998
  verify_cmd=verify_cmd,
@@ -3695,10 +4002,16 @@ def _build_cfg_from_yaml(args: argparse.Namespace, ap: argparse.ArgumentParser,
3695
4002
  worktree_root=_resolve_opt_path(args.worktree_root, raw.get("worktree_root")),
3696
4003
  add_dirs=[
3697
4004
  pathlib.Path(d).expanduser().resolve()
3698
- for d in (args.add_dirs or raw.get("add_dirs", []))
4005
+ for d in (
4006
+ args.add_dirs if args.add_dirs is not None
4007
+ else raw.get("add_dirs", [])
4008
+ )
3699
4009
  ],
3700
4010
  reasoning=args.reasoning or raw.get("reasoning"),
3701
- codex_fast=bool(args.codex_fast or raw.get("codex_fast", False)),
4011
+ codex_fast=bool(
4012
+ args.codex_fast
4013
+ if args.codex_fast is not None else raw.get("codex_fast", False)
4014
+ ),
3702
4015
  )
3703
4016
  cfg.agents = apply_resume_overrides(
3704
4017
  cfg.agents,
@@ -3717,12 +4030,13 @@ def _build_cfg_from_cli(args: argparse.Namespace, ap: argparse.ArgumentParser,
3717
4030
  agent in the "wrong" slot still routes its session id correctly.
3718
4031
  """
3719
4032
  cfg_cwd = pathlib.Path(args.cwd).expanduser().resolve()
4033
+ timeout = args.timeout if args.timeout is not None else DEFAULT_TIMEOUT
3720
4034
  task, kickoff = resolve_seed_inputs(
3721
4035
  task=args.task,
3722
4036
  kickoff=args.kickoff,
3723
4037
  task_from_cmd=args.task_from_cmd,
3724
4038
  cwd=cfg_cwd,
3725
- timeout=args.timeout,
4039
+ timeout=timeout,
3726
4040
  parser=ap,
3727
4041
  stdin_cache=stdin_cache,
3728
4042
  )
@@ -3748,11 +4062,14 @@ def _build_cfg_from_cli(args: argparse.Namespace, ap: argparse.ArgumentParser,
3748
4062
  task=task,
3749
4063
  kickoff=kickoff,
3750
4064
  max_turns=args.turns,
3751
- sentinel=args.sentinel,
3752
- per_turn_timeout=args.timeout,
4065
+ sentinel=args.sentinel if args.sentinel is not None else DEFAULT_SENTINEL,
4066
+ per_turn_timeout=timeout,
3753
4067
  runs_dir=choose_runs_dir(args.runs_dir, cfg_cwd),
3754
- sandbox=args.sandbox,
3755
- permission_mode=args.permission_mode,
4068
+ sandbox=args.sandbox if args.sandbox is not None else DEFAULT_SANDBOX,
4069
+ permission_mode=(
4070
+ args.permission_mode
4071
+ if args.permission_mode is not None else DEFAULT_PERMISSION_MODE
4072
+ ),
3756
4073
  dry_run=args.dry_run,
3757
4074
  recap=args.recap,
3758
4075
  verify_cmd=normalize_verify_cmd(args.verify_cmd, ap),
@@ -3760,7 +4077,10 @@ def _build_cfg_from_cli(args: argparse.Namespace, ap: argparse.ArgumentParser,
3760
4077
  worktree_for=args.worktree_for or "partner",
3761
4078
  worktree_path=_resolve_opt_path(args.worktree_path),
3762
4079
  worktree_root=_resolve_opt_path(args.worktree_root),
3763
- add_dirs=[pathlib.Path(d).expanduser().resolve() for d in args.add_dirs],
4080
+ add_dirs=[
4081
+ pathlib.Path(d).expanduser().resolve()
4082
+ for d in (args.add_dirs or [])
4083
+ ],
3764
4084
  reasoning=args.reasoning,
3765
4085
  codex_fast=bool(args.codex_fast),
3766
4086
  )
@@ -3804,7 +4124,7 @@ def main() -> int:
3804
4124
 
3805
4125
  # `--status` is read-only: print run health and exit. Skip everything below.
3806
4126
  if args.status:
3807
- return print_run_status(args.status)
4127
+ return print_run_status_safe(args.status)
3808
4128
 
3809
4129
  # `--list` is read-only: print the run-dir table and exit.
3810
4130
  if args.list_runs is not None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: duet-cli
3
- Version: 0.2.6
3
+ Version: 0.2.7
4
4
  Summary: Two CLI agents in conversation. One Python file. Stdlib only.
5
5
  Author: Volkan Altan
6
6
  License-Expression: MIT
@@ -166,6 +166,8 @@ duet --reasoning high --codex-fast \
166
166
  --task "Fix the issue" --cwd ~/workspace/project
167
167
  ```
168
168
 
169
+ Use `--no-codex-fast` to override a config or continued run that enabled it.
170
+
169
171
  **Verify gate** — a convergence proposal only counts if `make test` exits 0;
170
172
  any failure feeds back into the next turn:
171
173
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "duet-cli"
7
- version = "0.2.6"
7
+ version = "0.2.7"
8
8
  description = "Two CLI agents in conversation. One Python file. Stdlib only."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -3,8 +3,9 @@
3
3
  These tests cover the correctness-critical helpers that `scripts/smoke.sh`
4
4
  can't observe through exit codes alone: convergence detection, codex/copilot
5
5
  session-id parsing, recap header parsing, file-path heuristics, reasoning
6
- mappings, partner-spec parsing, markdown-fence sizing, and the age
7
- formatter. They are pure-function tests no subprocesses, no filesystem
6
+ mappings, partner-spec parsing, markdown-fence sizing, age formatting, and
7
+ bounded agent-error transcript formatting, continuation-state validation, and
8
+ status fallbacks. They are pure-function tests — no subprocesses, no filesystem
8
9
  writes, no agent CLIs.
9
10
 
10
11
  Run via:
@@ -17,6 +18,7 @@ from __future__ import annotations
17
18
  import contextlib
18
19
  import io
19
20
  import pathlib
21
+ import re
20
22
  import subprocess
21
23
  import sys
22
24
  import unittest
@@ -261,6 +263,30 @@ class TestVerifyHelpers(unittest.TestCase):
261
263
  # ---------- agent finish reasons ----------
262
264
 
263
265
 
266
+ class TestAgentFailureTranscript(unittest.TestCase):
267
+ def test_small_error_is_preserved_with_stderr_link(self) -> None:
268
+ log_path = pathlib.Path("/repo/runs/1/turn-01-codex.stderr.log")
269
+ block = duet.format_agent_error_for_transcript(
270
+ RuntimeError("backend failed"), log_path
271
+ )
272
+
273
+ self.assertIn("[duet] error: backend failed", block)
274
+ self.assertIn(f"[duet] stderr log: {log_path}", block)
275
+ self.assertNotIn("characters omitted", block)
276
+
277
+ def test_large_error_keeps_head_and_tail_with_omitted_count(self) -> None:
278
+ text = "HEAD-" + ("x" * 2000) + "-TAIL"
279
+ excerpt = duet._bounded_agent_error_excerpt(text, max_chars=240)
280
+
281
+ self.assertLessEqual(len(excerpt), 240)
282
+ self.assertTrue(excerpt.startswith("HEAD-"))
283
+ self.assertTrue(excerpt.endswith("-TAIL"))
284
+ match = re.search(r"(\d+) characters omitted", excerpt)
285
+ self.assertIsNotNone(match)
286
+ self.assertGreater(int(match.group(1)), 0)
287
+ self.assertNotIn("x" * 500, excerpt)
288
+
289
+
264
290
  class TestAgentFinishReasons(unittest.TestCase):
265
291
  def test_codex_rc_124_maps_to_timeout(self) -> None:
266
292
  def fake_run(cmd, **kwargs):
@@ -625,7 +651,12 @@ class TestParseCodexSessionId(unittest.TestCase):
625
651
  duet._parse_codex_session_id(f"see {self.UUID} for details")
626
652
  )
627
653
 
628
- def test_last_match_wins(self) -> None:
654
+ def test_inline_session_id_label_rejected(self) -> None:
655
+ self.assertIsNone(
656
+ duet._parse_codex_session_id(f"trace: session id: {self.UUID}\n")
657
+ )
658
+
659
+ def test_first_line_start_match_wins(self) -> None:
629
660
  first = "11111111-1111-1111-1111-111111111111"
630
661
  second = "22222222-2222-2222-2222-222222222222"
631
662
  stderr = (
@@ -633,7 +664,19 @@ class TestParseCodexSessionId(unittest.TestCase):
633
664
  "...\n"
634
665
  f"session id: {second}\n"
635
666
  )
636
- self.assertEqual(duet._parse_codex_session_id(stderr), second)
667
+ self.assertEqual(duet._parse_codex_session_id(stderr), first)
668
+
669
+ def test_existing_uuid_pin_not_replaced_by_different_parse(self) -> None:
670
+ existing = "11111111-1111-1111-1111-111111111111"
671
+ parsed = "22222222-2222-2222-2222-222222222222"
672
+ agent = duet.Agent(
673
+ name="codex-partner",
674
+ backend="codex",
675
+ role="coder",
676
+ session_id=existing,
677
+ )
678
+ with contextlib.redirect_stderr(io.StringIO()):
679
+ self.assertEqual(duet._resolve_codex_session_pin(agent, parsed), existing)
637
680
 
638
681
  def test_uuid_re_pattern_round_trip(self) -> None:
639
682
  # Sanity: _CODEX_UUID_RE matches a parsed UUID; rejects the
@@ -1092,6 +1135,18 @@ class TestBuildCfgFromCli(unittest.TestCase):
1092
1135
  self.assertIsNone(cfg.agents[0].model)
1093
1136
  self.assertIsNone(cfg.agents[1].model)
1094
1137
 
1138
+ def test_codex_fast_flags_are_tristate_and_mutually_exclusive(self) -> None:
1139
+ _, omitted = self._parse("--task", "x")
1140
+ _, enabled = self._parse("--task", "x", "--codex-fast")
1141
+ _, disabled = self._parse("--task", "x", "--no-codex-fast")
1142
+
1143
+ self.assertIsNone(omitted.codex_fast)
1144
+ self.assertIs(enabled.codex_fast, True)
1145
+ self.assertIs(disabled.codex_fast, False)
1146
+ with self.assertRaises(SystemExit), \
1147
+ contextlib.redirect_stderr(io.StringIO()):
1148
+ self._parse("--task", "x", "--codex-fast", "--no-codex-fast")
1149
+
1095
1150
 
1096
1151
  # ---------- apply_resume_overrides ----------
1097
1152
 
@@ -1470,6 +1525,140 @@ class TestFormatByteSize(unittest.TestCase):
1470
1525
  self.assertEqual(duet._format_byte_size(1536), "1.5KB")
1471
1526
 
1472
1527
 
1528
+ # ---------- continuation state helpers ----------
1529
+
1530
+
1531
+ class TestContinueStateHelpers(unittest.TestCase):
1532
+ def setUp(self) -> None:
1533
+ self.parser = duet.argparse.ArgumentParser()
1534
+
1535
+ def test_state_int_prefers_cli_then_parses_saved_value(self) -> None:
1536
+ self.assertEqual(
1537
+ duet._state_int_or_arg(
1538
+ 11, {"timeout": "bad"}, "timeout", 7,
1539
+ self.parser, "--continue",
1540
+ ),
1541
+ 11,
1542
+ )
1543
+ self.assertEqual(
1544
+ duet._state_int_or_arg(
1545
+ None, {"timeout": "13"}, "timeout", 7,
1546
+ self.parser, "--continue",
1547
+ ),
1548
+ 13,
1549
+ )
1550
+
1551
+ def test_state_int_rejects_malformed_saved_value(self) -> None:
1552
+ with self.assertRaises(SystemExit), \
1553
+ contextlib.redirect_stderr(io.StringIO()):
1554
+ duet._state_int_or_arg(
1555
+ None, {"timeout": "not-an-int"}, "timeout", 7,
1556
+ self.parser, "--continue",
1557
+ )
1558
+
1559
+ def test_state_str_prefers_cli_and_rejects_non_string_state(self) -> None:
1560
+ self.assertEqual(
1561
+ duet._state_str_or_arg(
1562
+ "plan", {"permission_mode": 42}, "permission_mode",
1563
+ duet.DEFAULT_PERMISSION_MODE, self.parser, "--continue",
1564
+ ),
1565
+ "plan",
1566
+ )
1567
+ with self.assertRaises(SystemExit), \
1568
+ contextlib.redirect_stderr(io.StringIO()):
1569
+ duet._state_str_or_arg(
1570
+ None, {"permission_mode": 42}, "permission_mode",
1571
+ duet.DEFAULT_PERMISSION_MODE, self.parser, "--continue",
1572
+ )
1573
+
1574
+ def test_state_list_prefers_cli_and_rejects_non_list_state(self) -> None:
1575
+ self.assertEqual(
1576
+ duet._state_list_or_arg(
1577
+ ["fresh"], {"add_dirs": "not-a-list"}, "add_dirs",
1578
+ self.parser, "--continue",
1579
+ ),
1580
+ ["fresh"],
1581
+ )
1582
+ with self.assertRaises(SystemExit), \
1583
+ contextlib.redirect_stderr(io.StringIO()):
1584
+ duet._state_list_or_arg(
1585
+ None, {"add_dirs": "not-a-list"}, "add_dirs",
1586
+ self.parser, "--continue",
1587
+ )
1588
+
1589
+ def test_authority_widening_state_requires_trust(self) -> None:
1590
+ args = duet.argparse.Namespace(
1591
+ verify_cmd=None,
1592
+ trust_state=False,
1593
+ dry_run=False,
1594
+ sandbox=None,
1595
+ permission_mode=None,
1596
+ add_dirs=None,
1597
+ )
1598
+ with self.assertRaises(SystemExit), \
1599
+ contextlib.redirect_stderr(io.StringIO()):
1600
+ duet._guard_continue_state_replay(
1601
+ {}, [], args, self.parser,
1602
+ sandbox="danger-full-access",
1603
+ permission_mode="bypassPermissions",
1604
+ add_dirs=["/"],
1605
+ )
1606
+
1607
+ def test_safe_or_explicit_authority_values_do_not_require_trust(self) -> None:
1608
+ safe_args = duet.argparse.Namespace(
1609
+ verify_cmd=None,
1610
+ trust_state=False,
1611
+ dry_run=False,
1612
+ sandbox=None,
1613
+ permission_mode=None,
1614
+ add_dirs=None,
1615
+ )
1616
+ duet._guard_continue_state_replay(
1617
+ {}, [], safe_args, self.parser,
1618
+ sandbox="read-only",
1619
+ permission_mode="plan",
1620
+ add_dirs=[],
1621
+ )
1622
+ override_args = duet.argparse.Namespace(
1623
+ verify_cmd=None,
1624
+ trust_state=False,
1625
+ dry_run=False,
1626
+ sandbox="workspace-write",
1627
+ permission_mode="acceptEdits",
1628
+ add_dirs=["fresh"],
1629
+ )
1630
+ duet._guard_continue_state_replay(
1631
+ {}, [], override_args, self.parser,
1632
+ sandbox="workspace-write",
1633
+ permission_mode="acceptEdits",
1634
+ add_dirs=["fresh"],
1635
+ )
1636
+
1637
+
1638
+ # ---------- status helpers ----------
1639
+
1640
+
1641
+ class TestStatusHelpers(unittest.TestCase):
1642
+ def test_coerce_pid_accepts_positive_integers_only(self) -> None:
1643
+ self.assertEqual(duet._coerce_pid(42), 42)
1644
+ self.assertEqual(duet._coerce_pid("42"), 42)
1645
+ self.assertEqual(duet._coerce_pid(42.0), 42)
1646
+ for value in (None, "bad", True, False, 0, -1, 1.5):
1647
+ with self.subTest(value=value):
1648
+ self.assertIsNone(duet._coerce_pid(value))
1649
+
1650
+ def test_status_safe_reports_traceback_and_returns_three(self) -> None:
1651
+ stderr = io.StringIO()
1652
+ with mock.patch.object(
1653
+ duet, "print_run_status", side_effect=RuntimeError("boom")), \
1654
+ contextlib.redirect_stderr(stderr):
1655
+ rc = duet.print_run_status_safe("run")
1656
+
1657
+ self.assertEqual(rc, 3)
1658
+ self.assertIn("status failed (RuntimeError): boom", stderr.getvalue())
1659
+ self.assertIn("Traceback (most recent call last)", stderr.getvalue())
1660
+
1661
+
1473
1662
  # ---------- normalize_verify_cmd ----------
1474
1663
 
1475
1664
 
File without changes
File without changes