Skip to content

Commit a4cbd56

Browse files
diwu1990claude
andcommitted
Wire resume for copilot, gemini, and qwen
last_thread_id searches each CLI's own store: copilot ~/.copilot/session-state (events log must record this cwd), gemini ~/.gemini/tmp/<sha256 of the directory>/chats with the full uuid read from the file body, qwen ~/.qwen/projects/<directory>/chats. Panes relaunch with the CLI's resume flag while the brief still arrives via -i. Resume ids are shell-quoted, since a qwen id can be a saved-chat tag. README also gains a separate uninstall block and drops the main-pane-width note from Requirements. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 9479805 commit a4cbd56

4 files changed

Lines changed: 91 additions & 29 deletions

File tree

‎README.md‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,25 @@ A tmux session of side-by-side AI coding agent CLIs that watch and message each
66

77
## Requirements
88

9-
- tmux 3.2 or newer (percentage `main-pane-width`)
9+
- tmux 3.2 or newer
1010
- At least one agent CLI installed (claude, codex, copilot, ...)
1111

1212
## Installation
1313

1414
```sh
1515
./install.sh # symlink as peon-code into ~/.local/bin
16-
peon-code uninstall [bin-dir] # remove that symlink
1716
```
1817

1918
After installation, run `peon-code` from any directory.
2019

2120
`install.sh` seeds `~/.config/peon-code/peon-code.conf` from the example if it is missing.
2221

22+
To uninstall:
23+
24+
```sh
25+
peon-code uninstall [bin-dir] # remove the install.sh symlink
26+
```
27+
2328
## Quick start
2429

2530
```sh
@@ -57,9 +62,9 @@ Detaching leaves the session alive. Use `dismiss` to actually stop it.
5762

5863
`resume` builds the session the same way as a plain start, except each agent reopens the conversation it had in that session and directory before, so the team keeps its memory across a `dismiss` or a reboot. The team, session name, and roles resolve exactly as they do for a start.
5964

60-
Each brief carries the phrase `agent <name> of peon-code session <session>,` (trailing comma included, so one session name that is a prefix of another never matches the other's transcripts), and that phrase is what `resume` looks for in the CLIs' own transcripts, newest first: `~/.claude/projects/<directory>/*.jsonl` for claude, `~/.codex/sessions/**/rollout-*.jsonl` for codex, where the rollout must also record the current working directory. The marker is unique per agent, so a team of any size reopens one conversation per pane rather than every same-CLI pane landing in the newest one. Transcripts older than 30 days are not searched.
65+
Each brief carries the phrase `agent <name> of peon-code session <session>,` (trailing comma included, so one session name that is a prefix of another never matches the other's transcripts), and that phrase is what `resume` looks for in the CLIs' own transcripts, newest first: `~/.claude/projects/<directory>/*.jsonl` for claude, `~/.codex/sessions/**/rollout-*.jsonl` for codex (the rollout must also record the current working directory), `~/.copilot/session-state/<id>/events.jsonl` for copilot (the events log must also record the current working directory), `~/.gemini/tmp/<sha256 of the directory>/chats/*.jsonl` for gemini, and `~/.qwen/projects/<directory>/chats/<id>.jsonl` for qwen. The marker is unique per agent, so a team of any size reopens one conversation per pane rather than every same-CLI pane landing in the newest one. Transcripts older than 30 days are not searched.
6166

62-
claude panes launch as `claude --resume <id>`, codex panes as `codex resume <id>`, both keeping the config's own arguments. Other providers have no resume handle here and start fresh. An agent with no earlier thread is named on stderr and starts fresh too.
67+
claude panes launch as `claude --resume <id>`, codex panes as `codex resume <id>`, copilot panes as `copilot --resume=<id>`, gemini and qwen panes as `<cli> --resume <id>`, all keeping the config's own arguments; copilot, gemini, and qwen still get the brief through `-i`. Any other provider has no resume handle here and starts fresh. An agent with no earlier thread is named on stderr and starts fresh too.
6368

6469
Each resumed pane still gets the full brief, since pane ids change with every session.
6570

@@ -113,8 +118,8 @@ A team without roles (agent commands on the command line, or a config of all `-`
113118
|----------|-----------------|----------------|
114119
| claude | launched bare, brief pasted in once its TUI is up | `--resume <id>` |
115120
| codex | positional prompt, stays interactive | `resume <id>` |
116-
| copilot | `-i <prompt>` (verified) | not supported |
117-
| gemini, qwen | `-i <prompt>` (unverified on this machine) | not supported |
121+
| copilot | `-i <prompt>` (verified) | `--resume=<id>` |
122+
| gemini, qwen | `-i <prompt>` (unverified on this machine) | `--resume <id>` |
118123
| anything else | passed through as-is | not supported |
119124

120125
Every brief is written to a file under `$TMPDIR`, and the pane launches with `<cmd> "$(cat <file>)"`, so the command line stays one line instead of thousands of escaped characters.

‎lib/config.sh‎

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,19 +20,28 @@ role_label() {
2020
}
2121

2222
# Previous thread of one agent, found by the marker phrase its brief carries:
23-
# the phrase names the agent and the session, and both CLIs record the prompt
23+
# the phrase names the agent and the session, and the CLIs record the prompt
2424
# in their transcript, so no launch-time bookkeeping is needed. Newest match
2525
# wins; empty output means there is nothing to resume and the pane starts new.
2626
# ponytail: the search is capped at 30 days of transcripts, the age past which
2727
# a thread is not worth reviving.
2828
last_thread_id() {
29-
local marker=$1 bin=$2 dir cwd="" found file id
29+
local marker=$1 bin=$2 dir cwd="" hash found file id
3030
case $bin in
31-
claude) dir="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/projects/${PWD//[^A-Za-z0-9]/-}" ;;
32-
codex) dir="${CODEX_HOME:-$HOME/.codex}/sessions"
33-
# Rollouts of every directory share this store; a match must also
34-
# carry the cwd line naming this directory.
35-
cwd="\"cwd\":\"$PWD\"" ;;
31+
claude) dir="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/projects/${PWD//[^A-Za-z0-9]/-}" ;;
32+
codex) dir="${CODEX_HOME:-$HOME/.codex}/sessions"
33+
# Rollouts of every directory share this store; a match must also
34+
# carry the cwd line naming this directory.
35+
cwd="\"cwd\":\"$PWD\"" ;;
36+
copilot) dir="$HOME/.copilot/session-state"
37+
# Session logs of every directory share this store; a match must
38+
# also carry the cwd line naming this directory.
39+
cwd="\"cwd\":\"$PWD\"" ;;
40+
gemini) # gemini keys its per-directory store by the SHA-256 of the path
41+
hash=$(printf '%s' "$PWD" | shasum -a 256 2>/dev/null) ||
42+
hash=$(printf '%s' "$PWD" | sha256sum 2>/dev/null) || return 0
43+
dir="$HOME/.gemini/tmp/${hash%% *}/chats" ;;
44+
qwen) dir="$HOME/.qwen/projects/${PWD//[^A-Za-z0-9]/-}/chats" ;;
3645
*) return 0 ;; # no known transcript store, so no resume handle
3746
esac
3847
[ -d "$dir" ] || return 0
@@ -43,9 +52,23 @@ last_thread_id() {
4352
while IFS= read -r file; do
4453
grep -qF -- "$marker" "$file" || continue
4554
[ -z "$cwd" ] || grep -qF -- "$cwd" "$file" || continue
46-
id=${file##*/}
47-
id=${id%.jsonl}
48-
printf '%s\n' "${id: -36}" # claude names the file by id, codex suffixes it
55+
case $bin in
56+
copilot) id=${file%/*} # <id>/events.jsonl: the directory is the id
57+
id=${id##*/} ;;
58+
gemini) # the filename holds 8 chars of the id; the body holds it all.
59+
# First occurrence in file order: the top-level sessionId comes
60+
# before any sessionId nested inside a message.
61+
id=$(grep -o '"sessionId"[[:space:]]*:[[:space:]]*"[^"]*"' "$file" | head -n 1) || true
62+
id=${id%\"}
63+
id=${id##*\"} ;;
64+
codex) id=${file##*/}
65+
id=${id%.jsonl}
66+
id=${id: -36} ;; # rollout-<timestamp>-<id>.jsonl
67+
*) id=${file##*/}
68+
id=${id%.jsonl} ;; # claude and qwen name the file by the id
69+
esac
70+
[ -n "$id" ] || continue
71+
printf '%s\n' "$id"
4972
return 0
5073
done < <(printf '%s\n' "$found" | tr '\n' '\0' | xargs -0 ls -t 2>/dev/null)
5174
}

‎peon-code.sh‎

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@
1212
# Team resolution: CLI agent commands > -c file > ./peon-code.conf >
1313
# ~/.config/peon-code/peon-code.conf > claude codex.
1414
# Known agents get the right "interactive session + initial prompt" launch:
15-
# claude launched bare, brief pasted in once its TUI is up
16-
# codex positional prompt, stays interactive (default passthrough)
17-
# copilot -i <prompt> (verified on this machine)
18-
# gemini, qwen -i <prompt> (documented; unverified on this machine)
15+
# claude launched bare, brief pasted in once its TUI is up; resumes with --resume <id>
16+
# codex positional prompt, stays interactive; resumes with resume <id>
17+
# copilot -i <prompt> (verified on this machine); resumes with --resume=<id>
18+
# gemini, qwen -i <prompt> (documented; unverified on this machine); resume with --resume <id>
1919
# Any other command is passed through as-is: <cmd> <quoted-brief>.
2020
set -euo pipefail
2121

@@ -27,8 +27,9 @@ usage() {
2727
cat <<'USAGE'
2828
peon-code.sh [-c file] [<session>] [<cmd> ...] start or attach a team
2929
(session defaults to the current directory name)
30-
peon-code.sh resume [<session>] [<cmd> ...] same, each agent reopening its
31-
last conversation (claude, codex)
30+
peon-code.sh resume [<session>] [<cmd> ...] same, each agent reopening its last
31+
conversation (claude, codex, copilot,
32+
gemini, qwen)
3233
peon-code.sh dismiss [<session>] kill one session
3334
(session defaults to the current directory name)
3435
peon-code.sh msg <name|all> 'text' [<session>] send text to an agent pane
@@ -204,16 +205,20 @@ EOF
204205
# Map known CLIs to their interactive-session-with-initial-prompt syntax.
205206
# First token is the binary; trailing user args are preserved before the flag.
206207
# A resume id, when there is one, goes right after the binary, since codex
207-
# takes it as a subcommand and both CLIs read their options after it.
208+
# takes it as a subcommand and every CLI reads its options after it. For
209+
# copilot, gemini, and qwen the id rides alongside -i: their flag
210+
# validation permits the pair, though their docs do not show it.
208211
BIN=${CMDS[$i]%% *}
209212
ARGS=""
210213
[ "${CMDS[$i]}" = "$BIN" ] || ARGS=" ${CMDS[$i]#* }"
211214
RID=${RESUME_IDS[$i]}
215+
# Quoted for the pane's shell: a qwen id can be a saved-chat tag, not a uuid.
216+
RID=${RID:+$(printf %q "$RID")}
212217
case "$BIN" in
213218
claude) LAUNCH="$BIN${RID:+ --resume $RID}$ARGS" ;; # bare, keeping user args; the brief follows the TUI
214219
codex) LAUNCH="$BIN${RID:+ resume $RID}$ARGS $Q" ;; # positional prompt, stays interactive
215-
copilot) LAUNCH="${CMDS[$i]} -i $Q" ;; # -i starts the interactive TUI and runs the prompt
216-
gemini|qwen) LAUNCH="${CMDS[$i]} -i $Q" ;; # unverified on this machine
220+
copilot) LAUNCH="$BIN${RID:+ --resume=$RID}$ARGS -i $Q" ;; # -i starts the interactive TUI and runs the prompt
221+
gemini|qwen) LAUNCH="$BIN${RID:+ --resume $RID}$ARGS -i $Q" ;; # unverified on this machine
217222
*) LAUNCH="${CMDS[$i]} $Q" ;; # anything else: positional prompt
218223
esac
219224
printf '%s' "$LAUNCH" | paste_to_pane "${PANE_IDS[$i]}"

‎tests/test_peon_code.sh‎

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -205,27 +205,48 @@ test_config_loading() {
205205
test_resume_picks_each_agent_thread() {
206206
local fake_bin=$1 log="$TEST_DIR/tmux-resume.log" home_dir="$TEST_DIR/home-resume"
207207
local work_dir="$TEST_DIR/resume-work" claude_dir codex_dir slug launch
208+
local copilot_dir gemini_dir qwen_dir hash
208209
mkdir -p "$home_dir" "$work_dir"
209210
work_dir=$(cd "$work_dir" && pwd) # the launcher sees the normalized path
210211
slug=${work_dir//[^A-Za-z0-9]/-}
211212
claude_dir="$home_dir/.claude/projects/$slug"
212213
codex_dir="$home_dir/.codex/sessions/2026/07/26"
213-
mkdir -p "$claude_dir" "$codex_dir"
214+
copilot_dir="$home_dir/.copilot/session-state/44444444-4444-4444-4444-444444444444"
215+
hash=$(printf '%s' "$work_dir" | shasum -a 256 2>/dev/null) ||
216+
hash=$(printf '%s' "$work_dir" | sha256sum)
217+
gemini_dir="$home_dir/.gemini/tmp/${hash%% *}/chats"
218+
qwen_dir="$home_dir/.qwen/projects/$slug/chats"
219+
mkdir -p "$claude_dir" "$codex_dir" "$copilot_dir" "$gemini_dir" "$qwen_dir"
214220
printf 'agent boss of peon-code session resume-test, in pane\n' \
215221
>"$claude_dir/11111111-1111-1111-1111-111111111111.jsonl"
216222
printf 'agent second of peon-code session resume-test, in pane\n' \
217223
>"$claude_dir/22222222-2222-2222-2222-222222222222.jsonl"
218224
printf '{"cwd":"%s"}\nagent impl of peon-code session resume-test, in pane\n' "$work_dir" \
219225
>"$codex_dir/rollout-2026-07-26T00-00-00-33333333-3333-3333-3333-333333333333.jsonl"
220-
sleep 1 # the decoy session's transcript sorts newer than the real ones
226+
printf '{"type":"session.start","data":{"context":{"cwd":"%s"}}}\nagent cop of peon-code session resume-test, in pane\n' "$work_dir" \
227+
>"$copilot_dir/events.jsonl"
228+
# One line holding a nested sessionId after the real one: the top-level id must win.
229+
printf '{"sessionId":"55555555-5555-5555-5555-555555555555","messages":[{"text":"agent gem of peon-code session resume-test, in pane"}],"extra":{"sessionId":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}}\n' \
230+
>"$gemini_dir/session-20260726-000000-55555555.jsonl"
231+
printf 'agent qw of peon-code session resume-test, in pane\n' \
232+
>"$qwen_dir/66666666-6666-6666-6666-666666666666.jsonl"
233+
# A qwen id can be a saved-chat tag shorter than a uuid.
234+
printf 'agent qw2 of peon-code session resume-test, in pane\n' \
235+
>"$qwen_dir/mytag.jsonl"
236+
sleep 1 # the decoy transcripts sort newer than the real ones
221237
printf 'agent boss of peon-code session resume-test2, in pane\n' \
222238
>"$claude_dir/99999999-9999-9999-9999-999999999999.jsonl"
223-
printf 'boss claude -\n*second claude -\nimpl codex manager\nscout codex -\n' >"$work_dir/peon-code.conf"
239+
# Same marker, other directory: the copilot store is shared across directories.
240+
mkdir -p "$home_dir/.copilot/session-state/88888888-8888-8888-8888-888888888888"
241+
printf '{"type":"session.start","data":{"context":{"cwd":"%s"}}}\nagent cop of peon-code session resume-test, in pane\n' "$work_dir/elsewhere" \
242+
>"$home_dir/.copilot/session-state/88888888-8888-8888-8888-888888888888/events.jsonl"
243+
printf 'boss claude -\n*second claude -\nimpl codex manager\nscout codex -\ncop copilot -\ngem gemini -\nqw qwen -\nqw2 qwen -\n' \
244+
>"$work_dir/peon-code.conf"
224245

225246
(
226247
cd "$work_dir"
227248
PATH="$fake_bin:$PATH" HOME="$home_dir" TMPDIR="$TEST_DIR" \
228-
FAKE_TMUX_LOG="$log" FAKE_TMUX_MODE=launch FAKE_TMUX_PANES=4 \
249+
FAKE_TMUX_LOG="$log" FAKE_TMUX_MODE=launch FAKE_TMUX_PANES=8 \
229250
"$ROOT/peon-code.sh" resume resume-test
230251
) >"$TEST_DIR/resume.out" 2>"$TEST_DIR/resume.err" || true
231252

@@ -235,9 +256,17 @@ test_resume_picks_each_agent_thread() {
235256
assert_contains <(printf '%s\n' "$launch") \
236257
"buffer-content:claude --resume 22222222-2222-2222-2222-222222222222"
237258
assert_contains "$log" "buffer-content:codex resume 33333333-3333-3333-3333-333333333333"
259+
assert_contains "$log" "buffer-content:copilot --resume=44444444-4444-4444-4444-444444444444 -i"
260+
assert_contains "$log" "buffer-content:gemini --resume 55555555-5555-5555-5555-555555555555 -i"
261+
assert_contains "$log" "buffer-content:qwen --resume 66666666-6666-6666-6666-666666666666 -i"
262+
assert_contains "$log" "buffer-content:qwen --resume mytag -i"
238263
assert_contains "$TEST_DIR/resume.err" "no earlier thread for scout"
239264
# A session name that is a prefix of another must not match its transcripts.
240265
assert_not_contains "$log" "99999999-9999-9999-9999-999999999999"
266+
# The copilot match from the other directory must not be resumed.
267+
assert_not_contains "$log" "88888888-8888-8888-8888-888888888888"
268+
# The nested sessionId in the gemini body must not be resumed.
269+
assert_not_contains "$log" "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
241270
# The *-marked agent (not the manager) is moved into the main slot of a
242271
# main-vertical layout, and the mark is stripped from its name.
243272
assert_contains "$log" "swap-pane -d -s %2 -t %1"

0 commit comments

Comments
 (0)