tandem-cli 0.5.0__tar.gz → 0.5.2__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 (113) hide show
  1. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/PKG-INFO +17 -1
  2. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/README.md +16 -0
  3. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/formats.md +43 -6
  4. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/how-it-works.md +2 -2
  5. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/plugin/.claude-plugin/plugin.json +1 -1
  6. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/pyproject.toml +1 -1
  7. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/compat.py +3 -3
  8. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/flip.py +23 -8
  9. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/harness/claude_code.py +28 -6
  10. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/harness/codex.py +20 -5
  11. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/ops.py +2 -13
  12. tandem_cli-0.5.2/tests/test_current_formats.py +189 -0
  13. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_flip.py +44 -44
  14. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_parse.py +7 -1
  15. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_participants.py +1 -1
  16. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_paths_compat.py +6 -3
  17. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_sync.py +8 -3
  18. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_toolmap.py +13 -0
  19. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/uv.lock +1 -1
  20. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/.claude-plugin/marketplace.json +0 -0
  21. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/.github/workflows/ci.yml +0 -0
  22. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/.gitignore +0 -0
  23. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/LICENSE +0 -0
  24. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/configuration.md +0 -0
  25. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/development.md +0 -0
  26. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/plans/2026-07-29-native-tool-call-translation.md +0 -0
  27. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/plans/2026-07-31-codex-subagents.md +0 -0
  28. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/plans/2026-08-01-plugin-marketplace-install.md +0 -0
  29. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/plans/2026-08-01-sandbox-consent-and-manual-routing.md +0 -0
  30. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/plans/2026-08-02-plugin-auto-install.md +0 -0
  31. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/plans/2026-08-03-manual-default-model-passthrough.md +0 -0
  32. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/plans/2026-08-06-harness-startup-args.md +0 -0
  33. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/plans/2026-08-09-meta-harness-frame.md +0 -0
  34. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/plans/2026-08-09-readme-restructure.md +0 -0
  35. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/plans/2026-08-10-idle-flip-status-probe.md +0 -0
  36. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/plans/2026-08-10-retire-interstitial-prompt.md +0 -0
  37. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/plans/2026-08-13-pr1-nharness-core.md +0 -0
  38. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/plans/2026-08-13-pr2-opencode-adapter.md +0 -0
  39. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/specs/2026-07-29-native-tool-call-translation-design.md +0 -0
  40. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/specs/2026-07-31-codex-subagents-design.md +0 -0
  41. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/specs/2026-08-02-plugin-auto-install-design.md +0 -0
  42. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/specs/2026-08-03-manual-default-model-passthrough-design.md +0 -0
  43. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/specs/2026-08-06-harness-startup-args-design.md +0 -0
  44. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/specs/2026-08-09-meta-harness-frame-design.md +0 -0
  45. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/specs/2026-08-09-model-pin-recovery-design.md +0 -0
  46. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/specs/2026-08-09-readme-restructure-design.md +0 -0
  47. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/specs/2026-08-10-remove-interstitial-prompt-design.md +0 -0
  48. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/specs/2026-08-13-opencode-harness-design.md +0 -0
  49. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/subagents.md +0 -0
  50. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/superpowers/plans/2026-07-29-tandem-shell.md +0 -0
  51. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/superpowers/plans/2026-08-11-process-warmup-live-validation.md +0 -0
  52. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/superpowers/plans/2026-08-11-process-warmup.md +0 -0
  53. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/superpowers/plans/2026-08-12-process-warmup-pipelined.md +0 -0
  54. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/superpowers/specs/2026-07-29-tandem-shell-design.md +0 -0
  55. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/superpowers/specs/2026-08-10-idle-flip-status-probe.md +0 -0
  56. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/superpowers/specs/2026-08-11-process-warmup-design.md +0 -0
  57. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/superpowers/specs/2026-08-12-process-warmup-pipelined-design.md +0 -0
  58. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/docs/why.md +0 -0
  59. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/plugin/README.md +0 -0
  60. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/plugin/agents/codex-worker.md +0 -0
  61. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/plugin/agents/gpt.md +0 -0
  62. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/plugin/hooks/hooks.json +0 -0
  63. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/__init__.py +0 -0
  64. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/cli.py +0 -0
  65. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/config.py +0 -0
  66. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/constants.py +0 -0
  67. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/converter.py +0 -0
  68. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/doctor.py +0 -0
  69. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/events.py +0 -0
  70. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/frame.py +0 -0
  71. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/harness/__init__.py +0 -0
  72. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/harness/base.py +0 -0
  73. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/harness/opencode.py +0 -0
  74. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/hookroute.py +0 -0
  75. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/memory_sync.py +0 -0
  76. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/modelcat.py +0 -0
  77. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/paths.py +0 -0
  78. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/pinstash.py +0 -0
  79. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/plugin_setup.py +0 -0
  80. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/ptyrun.py +0 -0
  81. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/ratelimit.py +0 -0
  82. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/runner.py +0 -0
  83. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/state.py +0 -0
  84. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/summarize.py +0 -0
  85. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/sync.py +0 -0
  86. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/tailer.py +0 -0
  87. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/toolmap.py +0 -0
  88. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/util.py +0 -0
  89. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/src/tandem/warm.py +0 -0
  90. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/conftest.py +0 -0
  91. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/golden/claude-probe.jsonl +0 -0
  92. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/golden/codex-probe.jsonl +0 -0
  93. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_cli.py +0 -0
  94. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_config.py +0 -0
  95. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_converter.py +0 -0
  96. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_frame.py +0 -0
  97. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_hookroute.py +0 -0
  98. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_memory_doctor.py +0 -0
  99. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_modelcat.py +0 -0
  100. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_opencode.py +0 -0
  101. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_opencode_oracle.py +0 -0
  102. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_ops.py +0 -0
  103. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_pinstash.py +0 -0
  104. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_plugin.py +0 -0
  105. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_plugin_setup.py +0 -0
  106. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_ptyrun.py +0 -0
  107. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_ratelimit.py +0 -0
  108. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_runner.py +0 -0
  109. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_state.py +0 -0
  110. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_sub.py +0 -0
  111. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_tail.py +0 -0
  112. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_usage.py +0 -0
  113. {tandem_cli-0.5.0 → tandem_cli-0.5.2}/tests/test_warm.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tandem-cli
3
- Version: 0.5.0
3
+ Version: 0.5.2
4
4
  Summary: Run Claude Code, Codex CLI, and opencode in one shared coding session with live trace sync.
5
5
  Project-URL: Homepage, https://github.com/Bhavya6187/tandem
6
6
  Project-URL: Repository, https://github.com/Bhavya6187/tandem
@@ -94,6 +94,22 @@ network calls — set `rate_limits = false` under `[frame]` in
94
94
  [Configuration](https://github.com/Bhavya6187/tandem/blob/main/docs/configuration.md)
95
95
  for the status bar's other settings.
96
96
 
97
+ **0.5.1 keeps up with the current CLIs.** claude 2.1.26x and codex 0.153
98
+ changed their session records, and codex now holds a writer lock on every
99
+ open thread. On those versions 0.5.0 failed `tandem doctor` and every flip
100
+ into claude on the new metadata records, warned that codex was out of range
101
+ at each start, and could lose a claude → codex flip to `already has an
102
+ active writer`. All three are fixed, and codex's list-shaped tool outputs
103
+ now reach the other side as text with their exit codes intact.
104
+
105
+ **0.5.2 stops claude complaining about synced turns.** claude 2.1.261
106
+ restores a session's model on resume from its last assistant entry, and a
107
+ claude session that had never run a turn of its own (claude → codex →
108
+ opencode → claude) opened with `Session model <synced> could not be
109
+ restored … using fable instead`. Synced entries now carry claude's own
110
+ `<synthetic>` tag until claude has run, which its resume skips; the synced
111
+ turns still render and still reach the model.
112
+
97
113
  ## Why tandem?
98
114
 
99
115
  - **Keep going when a model hits its limit.** The tab bar shows each
@@ -68,6 +68,22 @@ network calls — set `rate_limits = false` under `[frame]` in
68
68
  [Configuration](https://github.com/Bhavya6187/tandem/blob/main/docs/configuration.md)
69
69
  for the status bar's other settings.
70
70
 
71
+ **0.5.1 keeps up with the current CLIs.** claude 2.1.26x and codex 0.153
72
+ changed their session records, and codex now holds a writer lock on every
73
+ open thread. On those versions 0.5.0 failed `tandem doctor` and every flip
74
+ into claude on the new metadata records, warned that codex was out of range
75
+ at each start, and could lose a claude → codex flip to `already has an
76
+ active writer`. All three are fixed, and codex's list-shaped tool outputs
77
+ now reach the other side as text with their exit codes intact.
78
+
79
+ **0.5.2 stops claude complaining about synced turns.** claude 2.1.261
80
+ restores a session's model on resume from its last assistant entry, and a
81
+ claude session that had never run a turn of its own (claude → codex →
82
+ opencode → claude) opened with `Session model <synced> could not be
83
+ restored … using fable instead`. Synced entries now carry claude's own
84
+ `<synthetic>` tag until claude has run, which its resume skips; the synced
85
+ turns still render and still reach the model.
86
+
71
87
  ## Why tandem?
72
88
 
73
89
  - **Keep going when a model hits its limit.** The tab bar shows each
@@ -6,14 +6,21 @@ database) the CLIs wrote. Versions observed:
6
6
 
7
7
  | CLI | version | session storage |
8
8
  | --- | --- | --- |
9
- | Claude Code (`claude`) | 2.1.220 | `~/.claude/projects/<munged-cwd>/<sessionId>.jsonl` |
10
- | Codex CLI (`codex`) | 0.145.0 | `~/.codex/sessions/YYYY/MM/DD/rollout-<YYYY-MM-DDThh-mm-ss>-<uuidv7>.jsonl` |
9
+ | Claude Code (`claude`) | 2.1.261 | `~/.claude/projects/<munged-cwd>/<sessionId>.jsonl` |
10
+ | Codex CLI (`codex`) | 0.153.4 | `~/.codex/sessions/YYYY/MM/DD/rollout-<YYYY-MM-DDThh-mm-ss>-<uuidv7>.jsonl` |
11
11
  | opencode (`opencode`) | 1.18.15 | one SQLite database, the path `opencode db path` prints (see below) |
12
12
 
13
13
  Env overrides honored: `CLAUDE_CONFIG_DIR` (claude home), `CODEX_HOME` (codex
14
14
  home), `OPENCODE_DB` (opencode database), `TANDEM_HOME` (tandem state).
15
15
 
16
- ## Claude Code transcript (claude 2.1.220)
16
+ Rechecked 2026-09-05 on claude 2.1.261 / codex 0.153.4 (a live paired
17
+ session plus a four-turn claude→codex→claude→codex→claude probe): the
18
+ conversation-bearing shapes below are unchanged; the additions are marked
19
+ "2.1.26x" / "0.153". Codex's accepted range runs through 0.159; 0.160+
20
+ warns until rechecked. Unknown top-level record types still fail
21
+ validation rather than being silently declared compatible.
22
+
23
+ ## Claude Code transcript (claude 2.1.220, rechecked 2.1.261)
17
24
 
18
25
  - One JSONL file per session, named `<sessionId>.jsonl` (sessionId is a
19
26
  UUIDv4). Project directory name = cwd with every character outside
@@ -42,13 +49,32 @@ home), `OPENCODE_DB` (opencode database), `TANDEM_HOME` (tandem state).
42
49
  metadata (permission mode, AI-generated title, linked PR, worktree
43
50
  moves, file-backup tracking). Not conversation content; claude resumes
44
51
  transcripts containing them without complaint.
52
+ - 2.1.26x adds four more uuid-less metadata records: `atis-latch`
53
+ (`atis`, `sessionId`), `bridge-session` (`bridgeSessionId`,
54
+ `lastSequenceNum`, owner account/org — routine per-session identity,
55
+ `lastSequenceNum` constant 0), `cost-state` (accumulated cost, timing,
56
+ line counts, `modelUsage`) and `custom-title` (`customTitle`, set when
57
+ the user renames a session). Same handling as the batch above.
58
+ - 2.1.26x can put a `fallback` block (`{from: {model}, to: {model}}`) in
59
+ an assistant message when the model is switched mid-session. No prose;
60
+ tandem drops it like any other non-text assistant block.
45
61
  - Resume: `claude --resume <sessionId>` (from the same cwd). A new session
46
62
  can be pinned to a chosen id with `claude --session-id <uuid>`.
63
+ - 2.1.261 restores the session model on resume from the last assistant
64
+ entry whose `message.model` is a string other than `"<synthetic>"`
65
+ (claude's own tag on model-less stubs such as API-error messages) and
66
+ warns `Session model X could not be restored (not a model this version
67
+ of Claude Code recognizes) — using <default> instead` when that value is
68
+ not a model it knows. Tandem-rendered assistant entries therefore copy
69
+ the model claude last used in that transcript, and carry `"<synthetic>"`
70
+ until claude has run at all (a zero-turn claude flipped back into). The
71
+ `"<synced>"` placeholder tandem ≤0.5.1 wrote there drew the warning;
72
+ entries carrying it are still skipped when tandem derives the model.
47
73
  - Turn boundary: a `user` entry with string content starts a turn; an
48
74
  `assistant` line with `stop_reason: "end_turn"` ends it. The `Stop` hook
49
75
  (injectable per-invocation via `--settings '<json>'`) fires at turn end.
50
76
 
51
- ## Codex rollout (codex-cli 0.145.0)
77
+ ## Codex rollout (codex-cli 0.145.0, rechecked 0.153.4)
52
78
 
53
79
  - One JSONL file per session under a date-sharded dir; the session id
54
80
  (UUIDv7) is embedded in the filename. `~/.codex/session_index.jsonl` maps
@@ -70,9 +96,15 @@ home), `OPENCODE_DB` (opencode database), `TANDEM_HOME` (tandem state).
70
96
  - `function_call` — `{name, arguments: <json string>, call_id}` (e.g.
71
97
  `exec_command`).
72
98
  - `function_call_output` — `{call_id, output}` (chunked shell output with
73
- exit code header).
99
+ exit code header). `output` is a string, or (0.153, roughly half of
100
+ all outputs) a list of `{type: "input_text", text}` blocks that are
101
+ consecutive chunks of one output — flatten by concatenation. Same
102
+ for `custom_tool_call_output`.
74
103
  - `custom_tool_call` / `custom_tool_call_output` — `apply_patch` with
75
- `input` = patch text (`*** Begin Patch ...`).
104
+ `input` = patch text (`*** Begin Patch ...`). 0.153 code mode uses the
105
+ same pair with `name: "exec"` and JavaScript in `input` (plus `id`,
106
+ `status`, `internal_chat_message_metadata_passthrough`, all inert);
107
+ nested tool calls live inside that outer call/output.
76
108
  - `event_msg` — the **UI-facing** stream. `payload.type`: `task_started`
77
109
  (`turn_id`), `user_message`, `agent_message` (`phase`), `token_count`,
78
110
  `patch_apply_end` (`{stdout, success, changes: {path: {type, content}}}`),
@@ -89,6 +121,11 @@ home), `OPENCODE_DB` (opencode database), `TANDEM_HOME` (tandem state).
89
121
  grepping for these literals gets back, not a rejection.
90
122
  - `turn_context` — per turn: cwd, approval_policy, sandbox_policy, model.
91
123
  - `world_state` — environment snapshot.
124
+ - `token_usage_record` (0.153) — per-response accounting: thread/turn/
125
+ response ids plus `usage`, `turn_token_usage`, `thread_token_usage`.
126
+ Not model history. `event_msg/token_count` still appears and remains
127
+ the usage meter's source — never sum the two. `event_msg/
128
+ thread_settings_applied` is likewise bookkeeping.
92
129
  - Resume: `codex resume <session-id>` (interactive) and
93
130
  `codex exec resume <session-id> "<prompt>"` (one-shot). Turn-complete
94
131
  notification hook: `-c 'notify=["/bin/sh","-c","..."]'` per invocation.
@@ -133,8 +133,8 @@ tandem pins what it was built against (observed formats documented in
133
133
 
134
134
  | CLI | Tested | Accepted range |
135
135
  | --- | --- | --- |
136
- | Claude Code | 2.1.220 | ≥ 2.0, < 3 |
137
- | Codex CLI | 0.145.0 | ≥ 0.140, < 0.150 |
136
+ | Claude Code | 2.1.261 | ≥ 2.0, < 3 |
137
+ | Codex CLI | 0.153.4 | ≥ 0.140, < 0.160 |
138
138
  | opencode | 1.18.15 | ≥ 1.18 (no ceiling) |
139
139
 
140
140
  Above a range's ceiling, tandem warns and asks you to run `tandem doctor`
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tandem",
3
3
  "description": "Route Claude Code subagent dispatches to cheap codex models via tandem.",
4
- "version": "0.5.0",
4
+ "version": "0.5.2",
5
5
  "author": {"name": "tandem"}
6
6
  }
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tandem-cli"
3
- version = "0.5.0"
3
+ version = "0.5.2"
4
4
  description = "Run Claude Code, Codex CLI, and opencode in one shared coding session with live trace sync."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -2,7 +2,7 @@
2
2
 
3
3
  Both session formats are internal to their CLIs and drift between releases.
4
4
  Tandem pins the ranges it was built against; outside a range we warn and
5
- require `tandem doctor` before enabling sync (see cli.py).
5
+ recommend `tandem doctor` before trusting sync (see cli.py).
6
6
  """
7
7
 
8
8
  from __future__ import annotations
@@ -23,8 +23,8 @@ class CompatRange:
23
23
 
24
24
  # Format observations in docs/formats.md correspond to these versions.
25
25
  COMPAT: dict[str, CompatRange] = {
26
- "claude": CompatRange(tested="2.1.220", min_version=(2, 0), max_exclusive=(3,)),
27
- "codex": CompatRange(tested="0.145.0", min_version=(0, 140), max_exclusive=(0, 150)),
26
+ "claude": CompatRange(tested="2.1.261", min_version=(2, 0), max_exclusive=(3,)),
27
+ "codex": CompatRange(tested="0.153.4", min_version=(0, 140), max_exclusive=(0, 160)),
28
28
  # Floor-only by operator decision (spec: Compat gate). Pre-1.18 opencode
29
29
  # predates SQLite session storage and genuinely cannot work.
30
30
  "opencode": CompatRange(tested="1.18.15", min_version=(1, 18)),
@@ -31,8 +31,8 @@ def run_session(tandem_id: str, sink_factory, run_harness=None) -> int:
31
31
  # The standby travels across flips here: popped for adoption by the
32
32
  # next run, refilled from the runner that just ended. Injected test
33
33
  # runners never touch it. `reapers` collects the threads tearing down
34
- # standbys the gate rejected — the flip does not wait for them, this
35
- # function's finally does.
34
+ # standbys the gate rejected for the wrong side — the flip does not
35
+ # wait for those, this function's finally does.
36
36
  carry: dict = {"standby": None, "reapers": []}
37
37
  if run_harness is None:
38
38
 
@@ -104,9 +104,10 @@ def _reap(standby, carry: dict) -> None:
104
104
  session's exit can join it.
105
105
 
106
106
  The kill ladder is slow by construction — quit keystrokes, then the
107
- unconditional soft/term waits — so killing a stale standby inline would
108
- put seconds between the user's Ctrl-] and the next harness starting,
109
- on precisely the flips warmup exists to speed up. The child is
107
+ unconditional soft/term waits — so killing a stale standby inline puts
108
+ seconds between the user's Ctrl-] and the next harness starting, on
109
+ precisely the flips warmup exists to speed up. Only affordable when the
110
+ standby and the relaunch share no session (see `_switch`); the child is
110
111
  already out of the carry, so nothing can adopt it while it dies."""
111
112
  thread = threading.Thread(
112
113
  target=_kill, args=(standby,), name="tandem-warm-reap", daemon=True
@@ -226,8 +227,11 @@ def _switch(
226
227
  and the state it snapshotted are finally comparable against what the
227
228
  next run will actually launch. A stale one is killed here and nowhere
228
229
  else — the runner silently ignores an adoptee it cannot use, so a wrong
229
- side that survived this gate would leak — but killed on a reaper thread,
230
- since the ladder's own timeouts would otherwise be charged to the flip."""
230
+ side that survived this gate would leak. One warmed for the side about
231
+ to launch dies inline, before the relaunch: it holds that side's session
232
+ open, and codex refuses a second writer on a thread. One warmed for any
233
+ other side dies on a reaper thread, since the ladder's own timeouts
234
+ would otherwise be charged to the flip for nothing."""
231
235
  with StateStore() as store:
232
236
  session = store.get_session(tandem_id)
233
237
  if session is None:
@@ -264,7 +268,18 @@ def _switch(
264
268
  _flip_debug(f"standby-gate side={new_active} fresh={fresh}")
265
269
  if not fresh:
266
270
  carry["standby"] = None
267
- _reap(standby, carry) # off-thread: the flip must not wait
271
+ if standby.recipe.side == new_active:
272
+ # It resumed the very session the cold spawn is about
273
+ # to resume, and codex (0.153+) holds a per-thread
274
+ # writer lock for as long as the process lives: a
275
+ # relaunch that overlaps its ladder fails with
276
+ # `thread ... already has an active writer` and ends
277
+ # the session. Killed inline — the ladder's seconds
278
+ # are the price of a flip that lands at all.
279
+ _kill(standby)
280
+ _flip_debug(f"standby-reaped side={new_active} inline")
281
+ else:
282
+ _reap(standby, carry) # off-thread: nothing in common
268
283
  code, flip, launched = _try_enter(tandem_id, run_harness)
269
284
  if launched:
270
285
  return code, flip
@@ -1,6 +1,7 @@
1
1
  """Claude Code adapter.
2
2
 
3
- Session format observed on claude 2.1.220 (docs/formats.md):
3
+ Session format observed on claude 2.1.220, rechecked on 2.1.261
4
+ (docs/formats.md):
4
5
  - transcript: ~/.claude/projects/<munged-cwd>/<sessionId>.jsonl
5
6
  - entries chained by uuid/parentUuid; types: user, assistant, attachment,
6
7
  queue-operation, last-prompt, summary, system
@@ -116,12 +117,28 @@ _CLAUDE_ENTRY_TYPES = {
116
117
  "user", "assistant", "attachment", "system", "summary",
117
118
  "queue-operation", "last-prompt", "progress", "file-history-snapshot",
118
119
  "mode", # {"type":"mode","mode":"normal",...} observed on --resume runs
119
- # uuid-less metadata entries claude 2.1.220 interleaves with conversation
120
+ # uuid-less session metadata claude interleaves with the conversation;
121
+ # none of it is portable content. 2.1.220 batch, then the 2.1.26x batch
122
+ # (session latch, remote-bridge identity, accumulated cost/timing, a
123
+ # user-set title).
120
124
  "permission-mode", "ai-title", "file-history-delta", "pr-link",
121
125
  "relocated", "worktree-state",
126
+ "atis-latch", "bridge-session", "cost-state", "custom-title",
122
127
  }
123
128
 
124
129
 
130
+ # claude's own tag for an assistant entry no model produced (its API-error
131
+ # stubs carry it). `claude --resume` (2.1.261) restores the session model from
132
+ # the last assistant entry and warns "Session model X could not be restored"
133
+ # for anything it does not recognise; "<synthetic>" is the one value that scan
134
+ # skips, so rendered entries carry it until claude itself has run and a real
135
+ # model can be copied instead (live-verified 2026-09-05: no warning, synced
136
+ # turns still render and reach the model).
137
+ SYNTHETIC_MODEL = "<synthetic>"
138
+ # what tandem <= 0.5.1 wrote in that slot; still skipped when deriving
139
+ LEGACY_SYNCED_MODEL = "<synced>"
140
+
141
+
125
142
  class ClaudeCodeAdapter(HarnessAdapter):
126
143
  id = "claude"
127
144
  display_name = "Claude Code"
@@ -153,6 +170,10 @@ class ClaudeCodeAdapter(HarnessAdapter):
153
170
  etype = e.get("type")
154
171
  if etype not in _CLAUDE_ENTRY_TYPES:
155
172
  problems.append(f"line {i}: unknown entry type {etype!r}")
173
+ if e.get("uuid"):
174
+ # still a chain participant: one unknown type must not
175
+ # cascade into a dangling-parent problem per later entry
176
+ seen_uuids.add(e["uuid"])
156
177
  continue
157
178
  if etype in ("user", "assistant"):
158
179
  convo += 1
@@ -425,7 +446,7 @@ class ClaudeCodeAdapter(HarnessAdapter):
425
446
  st["run_msg_id"] = f"msg_tandem_{entry['uuid'][:8]}"
426
447
  return {
427
448
  "role": "assistant",
428
- "model": st.get("model") or "<synced>",
449
+ "model": st.get("model") or SYNTHETIC_MODEL,
429
450
  "id": st["run_msg_id"],
430
451
  "type": "message",
431
452
  "content": content,
@@ -478,14 +499,15 @@ class ClaudeCodeAdapter(HarnessAdapter):
478
499
 
479
500
  def derive_last_model(self, transcript: Path) -> str | None:
480
501
  """Model of the last main-thread assistant entry with a real model
481
- name. Skips the "<synced>" placeholder (pre-fix tandem appends) and
482
- sidechain entries (subagents may run on a different model)."""
502
+ name. Skips tandem's own tags (SYNTHETIC_MODEL now, the older
503
+ "<synced>"), claude's synthetic stubs, and sidechain entries
504
+ (subagents may run on a different model)."""
483
505
  model = None
484
506
  for obj in self._tail_objects(transcript):
485
507
  if obj.get("type") != "assistant" or obj.get("isSidechain"):
486
508
  continue
487
509
  m = (obj.get("message") or {}).get("model")
488
- if m and m != "<synced>":
510
+ if m and m not in (SYNTHETIC_MODEL, LEGACY_SYNCED_MODEL):
489
511
  model = m
490
512
  return model
491
513
 
@@ -1,6 +1,7 @@
1
1
  """Codex CLI adapter.
2
2
 
3
- Session format observed on codex-cli 0.145.0 (docs/formats.md):
3
+ Session format observed on codex-cli 0.145.0, rechecked on 0.153.4
4
+ (docs/formats.md):
4
5
  - rollout: ~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<uuidv7>.jsonl
5
6
  - each line {timestamp, type, payload}; model-facing history is the
6
7
  response_item lines, UI history is the event_msg lines
@@ -76,9 +77,25 @@ class _CodexUsageMeter(UsageMeter):
76
77
  _CODEX_LINE_TYPES = {
77
78
  "session_meta", "response_item", "event_msg", "turn_context",
78
79
  "world_state", "compacted",
80
+ "token_usage_record", # 0.153.4 per-response accounting (not model history)
79
81
  }
80
82
 
81
83
 
84
+ def output_text(output: Any) -> str:
85
+ """Flatten a `*_output` payload to the text the model saw. Codex writes
86
+ either a plain string or (0.153: about half the time) a list of
87
+ `{"type": "input_text", "text": ...}` blocks — consecutive chunks of one
88
+ output, so they concatenate without a separator."""
89
+ if isinstance(output, str):
90
+ return output
91
+ if isinstance(output, list):
92
+ return "".join(
93
+ b["text"] for b in output
94
+ if isinstance(b, dict) and isinstance(b.get("text"), str)
95
+ )
96
+ return "" if output is None else str(output)
97
+
98
+
82
99
  class CodexAdapter(HarnessAdapter):
83
100
  id = "codex"
84
101
  display_name = "Codex CLI"
@@ -298,13 +315,11 @@ class CodexAdapter(HarnessAdapter):
298
315
  call_id = payload.get("call_id")
299
316
  pending = ctx.pending_calls.get(call_id or "", {})
300
317
  structured = pending.pop("_structured", None) if pending else None
301
- output = payload.get("output", "")
302
- if not isinstance(output, str):
303
- output = str(output)
304
318
  return [
305
319
  ToolResult(
306
320
  source="codex", timestamp=ts, turn_index=ctx.turn_index,
307
- call_id=call_id, output=output, structured=structured,
321
+ call_id=call_id, output=output_text(payload.get("output")),
322
+ structured=structured,
308
323
  )
309
324
  ]
310
325
  return [sysev(f"response_item:{ptype}")]
@@ -24,6 +24,7 @@ from pathlib import Path
24
24
 
25
25
  from . import paths
26
26
  from .harness import get_adapter
27
+ from .harness.codex import output_text
27
28
  from .runner import TailLoop, await_codex_rollout
28
29
  from .state import PairedSession, StateStore
29
30
  from .sync import SyncEngine, SyncSetupError
@@ -492,18 +493,6 @@ _PATCH_TARGET_RE = re.compile(
492
493
  r"""\*\*\* (?:Add|Update|Delete) File: (.+?)(?=\\n|[\n"']|$)""")
493
494
 
494
495
 
495
- def _output_text(output) -> str:
496
- """Flatten a custom_tool_call_output payload: codex writes either a plain
497
- string or a list of {"type": "input_text", "text": …} blocks."""
498
- if isinstance(output, str):
499
- return output
500
- if isinstance(output, list):
501
- return "\n".join(
502
- b["text"] for b in output
503
- if isinstance(b, dict) and isinstance(b.get("text"), str))
504
- return ""
505
-
506
-
507
496
  def blocked_write_paths(sub_path: Path, *, since: int = 0) -> list[str]:
508
497
  """Paths whose apply_patch the sandbox rejected during this run, in event
509
498
  order. Anything unreadable or unexpected yields [] — detection is
@@ -565,7 +554,7 @@ def blocked_write_paths(sub_path: Path, *, since: int = 0) -> list[str]:
565
554
  continue
566
555
  # ... and one grep for both literals passes that gate on its own,
567
556
  # so the marker must also sit where codex puts it: line-anchored.
568
- if not _PATCH_REJECTED_RE.search(_output_text(p.get("output"))):
557
+ if not _PATCH_REJECTED_RE.search(output_text(p.get("output"))):
569
558
  continue
570
559
  add(_PATCH_TARGET_RE.findall(src))
571
560
  return rejected
@@ -0,0 +1,189 @@
1
+ """Shapes observed with Claude 2.1.261 / Codex 0.153.4 (September 2026)."""
2
+
3
+ import pytest
4
+
5
+ from conftest import claude_assistant, claude_user, write_line
6
+ from tandem.cli import _resolve_participants
7
+ from tandem.converter import ReferenceConverter
8
+ from tandem.doctor import validate_transcript
9
+ from tandem.events import SessionContext
10
+ from tandem.harness import ADAPTERS
11
+ from tandem.ops import drain_source, switch_session
12
+ from tandem.util import read_jsonl
13
+
14
+ # uuid-less session metadata claude 2.1.26x interleaves with the conversation
15
+ _CLAUDE_METADATA = [
16
+ {"type": "atis-latch", "atis": ""},
17
+ {"type": "bridge-session", "bridgeSessionId": "cse_probe",
18
+ "lastSequenceNum": 0, "ownerAccountUuid": "account",
19
+ "ownerOrganizationUuid": "organization"},
20
+ {"type": "cost-state", "totalCostUSD": 0.01, "totalAPIDuration": 100,
21
+ "totalAPIDurationWithoutRetries": 100, "totalToolDuration": 0,
22
+ "totalLinesAdded": 0, "totalLinesRemoved": 0, "totalDuration": 150,
23
+ "startTime": 1788624000000, "modelUsage": {}, "hasUnknownModelCost": False},
24
+ {"type": "custom-title", "customTitle": "Renamed by the user"},
25
+ ]
26
+
27
+
28
+ @pytest.mark.parametrize("record", _CLAUDE_METADATA, ids=lambda r: r["type"])
29
+ def test_claude_session_metadata_is_valid_and_not_conversation(env_factory, record):
30
+ env = env_factory()
31
+ sid = env.session.native_id("claude")
32
+ record = dict(record, sessionId=sid) # never mutate the parametrize table
33
+ write_line(env.claude_shadow, record)
34
+ assert validate_transcript("claude", env.claude_shadow, sid) == []
35
+ ctx = SessionContext(tandem_id="probe", cwd=env.cwd, direction="claude->codex")
36
+ assert ReferenceConverter().translate_entry(record, ctx.direction, ctx) == []
37
+ assert ctx.turn_index == 0
38
+
39
+
40
+ def test_claude_model_fallback_block_is_dropped_not_text(env_factory):
41
+ """2.1.26x records a mid-session model switch as an assistant content
42
+ block; it carries no prose, so it must translate to nothing rather than
43
+ to an assistant message or a translation failure."""
44
+ env = env_factory()
45
+ entry = claude_assistant(
46
+ [{"type": "fallback", "from": {"model": "claude-fable-5"},
47
+ "to": {"model": "claude-opus-4-8"}}], uuid="a-fallback")
48
+ ctx = SessionContext(tandem_id="probe", cwd=env.cwd, direction="claude->codex")
49
+ assert ReferenceConverter().translate_entry(entry, ctx.direction, ctx) == []
50
+
51
+
52
+ def test_unknown_uuid_carrying_entry_does_not_cascade_into_parent_problems(env_factory):
53
+ """A future chain participant claude has not told us about yet is ONE
54
+ problem, not one plus a dangling-parent complaint for every entry chained
55
+ after it (which would bury the real cause under doctor's `... N more`)."""
56
+ env = env_factory()
57
+ sid = env.session.native_id("claude")
58
+ write_line(env.claude_shadow, {"type": "novel-chain-entry", "uuid": "novel-1",
59
+ "sessionId": sid})
60
+ write_line(env.claude_shadow, {**claude_user("after the novelty", uuid="u-after"),
61
+ "parentUuid": "novel-1"})
62
+ problems = validate_transcript("claude", env.claude_shadow, sid)
63
+ assert problems == ["line 1: unknown entry type 'novel-chain-entry'"]
64
+
65
+
66
+ def usage_record():
67
+ usage = {"input_tokens": 100, "cached_input_tokens": 64,
68
+ "cache_write_input_tokens": 0, "output_tokens": 10,
69
+ "reasoning_output_tokens": 2, "total_tokens": 110}
70
+ return {"timestamp": "2026-09-05T16:00:00.000Z", "type": "token_usage_record",
71
+ "payload": {"thread_id": "thread", "turn_id": "turn",
72
+ "session_id": "thread", "root_turn_id": "turn",
73
+ "response_id": "response", "usage": usage,
74
+ "turn_token_usage": usage, "thread_token_usage": usage}}
75
+
76
+
77
+ def test_codex_accounting_record_is_valid_and_not_conversation(env_factory):
78
+ env = env_factory()
79
+ record = usage_record()
80
+ write_line(env.codex_shadow, record)
81
+ assert validate_transcript("codex", env.codex_shadow,
82
+ env.session.native_id("codex")) == []
83
+ ctx = SessionContext(tandem_id="probe", cwd=env.cwd, direction="codex->claude")
84
+ assert ReferenceConverter().translate_entry(record, ctx.direction, ctx) == []
85
+ assert ctx.turn_index == 0
86
+
87
+
88
+ def _blocks(text_parts):
89
+ """codex 0.153 writes roughly half of its tool outputs as a list of
90
+ input_text blocks rather than one string (531 of ~5000 local outputs)."""
91
+ return [{"type": "input_text", "text": t} for t in text_parts]
92
+
93
+
94
+ def test_codex_list_shaped_tool_output_parses_to_joined_text():
95
+ adapter = ADAPTERS["codex"]
96
+ ctx = SessionContext(tandem_id="probe", cwd="/tmp", direction="codex->claude")
97
+ (ev,) = adapter.parse_entry(
98
+ {"type": "response_item", "payload": {
99
+ "type": "custom_tool_call_output", "call_id": "c1",
100
+ "output": _blocks(["Script completed\nOutput:\n", "cobalt\n"])}},
101
+ ctx)
102
+ assert ev.kind == "tool_result"
103
+ assert ev.output == "Script completed\nOutput:\ncobalt\n"
104
+ assert "input_text" not in ev.output
105
+
106
+
107
+ def _codex_turn_with_tools(user_text, assistant_text):
108
+ """One 0.153 turn: a code-mode `exec` call (list-shaped output), an
109
+ exec_command whose list-shaped output reports a failure, the new
110
+ accounting record, then the final answer."""
111
+ return [
112
+ {"timestamp": "t", "type": "event_msg",
113
+ "payload": {"type": "task_started", "turn_id": "x"}},
114
+ {"timestamp": "t", "type": "event_msg",
115
+ "payload": {"type": "user_message", "message": user_text}},
116
+ {"timestamp": "t", "type": "response_item", "payload": {
117
+ "type": "custom_tool_call", "id": "tool-id", "status": "completed",
118
+ "call_id": "call-exec", "name": "exec",
119
+ "input": 'text(await tools.exec_command({cmd:"printf cobalt"}));',
120
+ "internal_chat_message_metadata_passthrough": {}}},
121
+ {"timestamp": "t", "type": "response_item", "payload": {
122
+ "type": "custom_tool_call_output", "call_id": "call-exec",
123
+ "output": _blocks(["Script completed\nOutput:\n", "cobalt"])}},
124
+ {"timestamp": "t", "type": "response_item", "payload": {
125
+ "type": "function_call", "name": "exec_command",
126
+ "arguments": '{"cmd": "false"}', "call_id": "call-shell"}},
127
+ {"timestamp": "t", "type": "response_item", "payload": {
128
+ "type": "function_call_output", "call_id": "call-shell",
129
+ "output": _blocks(["Process exited with code 1\nOutput:\n", "boom"])}},
130
+ usage_record(),
131
+ {"timestamp": "t", "type": "response_item",
132
+ "payload": {"type": "message", "role": "assistant", "phase": "final_answer",
133
+ "content": [{"type": "output_text", "text": assistant_text}]}},
134
+ {"timestamp": "t", "type": "event_msg",
135
+ "payload": {"type": "task_complete", "turn_id": "x",
136
+ "last_agent_message": assistant_text}},
137
+ ]
138
+
139
+
140
+ def _claude_tool_blocks(transcript):
141
+ blocks = [b for e in read_jsonl(transcript)
142
+ for b in (e.get("message") or {}).get("content") or []
143
+ if isinstance(b, dict)]
144
+ return ([b for b in blocks if b["type"] == "tool_use"],
145
+ [b for b in blocks if b["type"] == "tool_result"])
146
+
147
+
148
+ def test_current_formats_survive_repeated_switches_without_echo(env_factory):
149
+ env = env_factory()
150
+ write_line(env.claude_shadow, claude_user("remember apricot", uuid="u-probe"))
151
+ write_line(env.claude_shadow, {"type": "atis-latch", "atis": ""})
152
+ write_line(env.claude_shadow, claude_assistant(
153
+ [{"type": "text", "text": "apricot remembered"}], uuid="a-probe"))
154
+ assert switch_session(env.store, env.session, "codex")[1] == []
155
+ env.refresh()
156
+ records = _codex_turn_with_tools("remember cobalt", "cobalt remembered")
157
+ for record in records:
158
+ write_line(env.codex_shadow, record)
159
+ assert switch_session(env.store, env.session, "claude")[1] == []
160
+ env.refresh()
161
+
162
+ calls, results = _claude_tool_blocks(env.claude_shadow)
163
+ assert [c["name"] for c in calls] == ["exec", "Bash"]
164
+ assert [r["tool_use_id"] for r in results] == [c["id"] for c in calls]
165
+ # code-mode call: JavaScript kept verbatim, list output flattened to text
166
+ assert calls[0]["input"]["input"] == records[2]["payload"]["input"]
167
+ assert results[0]["content"] == "Script completed\nOutput:\ncobalt"
168
+ assert results[0]["is_error"] is False
169
+ # exec_command with a list-shaped failure still maps to a failed Bash
170
+ assert calls[1]["input"] == {"command": "false"}
171
+ assert results[1]["content"] == "boom"
172
+ assert results[1]["is_error"] is True
173
+
174
+ before = env.codex_shadow.read_bytes(), env.claude_shadow.read_bytes()
175
+ drain_source(env.store, env.session, "claude")
176
+ assert switch_session(env.store, env.session, "codex")[1] == []
177
+ assert before == (env.codex_shadow.read_bytes(), env.claude_shadow.read_bytes())
178
+ for source, target in [("claude", "codex"), ("codex", "claude")]:
179
+ assert env.store.get_cursor(env.session.tandem_id, source, target).failed_turns == 0
180
+
181
+
182
+ def test_installed_versions_start_without_format_warning(monkeypatch, capsys):
183
+ monkeypatch.setattr("tandem.config.load_harnesses", lambda: ["claude", "codex"])
184
+ for hid, version in [("claude", "2.1.261 (Claude Code)"),
185
+ ("codex", "codex-cli 0.153.4")]:
186
+ monkeypatch.setattr(type(ADAPTERS[hid]), "detect_version",
187
+ lambda self, _v=version: _v)
188
+ assert _resolve_participants()[0] == ["claude", "codex"]
189
+ assert capsys.readouterr().err == ""