Skip to content

Read and fork GitHub Copilot CLI sessions - #9

Merged
wilbeibi merged 3 commits into
mainfrom
copilot-provider
Aug 26, 2026
Merged

Read and fork GitHub Copilot CLI sessions#9
wilbeibi merged 3 commits into
mainfrom
copilot-provider

Conversation

@wilbeibi

@wilbeibi wilbeibi commented Aug 25, 2026

Copy link
Copy Markdown
Owner

Closes #8, where a user running Claude, Copilot, and Cursor asks for handoff from Copilot. catchup read two of those three, so a Copilot session could only ever be continued in Copilot.

catchup copilot                     # recap the newest Copilot session here
catchup fork copilot                # pick the work back up in Copilot
catchup fork copilot --into codex   # or hand it to another agent
catchup fork claude --into copilot  # or hand another agent's session to Copilot

Approach

Copilot keeps one directory per session under $COPILOT_HOME/session-state (default ~/.copilot): workspace.yaml for metadata, events.jsonl for the append-only log. Resuming appends to that same log, so a session is always one file — no cross-file stitching. The directory name is the session id: what --resume takes, and what every listing reports, so a listed id resolves by construction.

Event shapes come from schemas/session-events.schema.json, which the CLI ships. Two judgments it settles, both of which the first commit guessed wrong:

  • Sub-agent turns reuse user.message and assistant.message and are marked only by the envelope's agentId — "absent for events from the root/main agent". They are a parent turn's tool plumbing, so every event carrying an agentId is skipped, the model included: a sub-agent routed elsewhere would otherwise overwrite the session's.
  • session.compaction_complete carries success and, on success, summaryContent. The marker carries that summary, so --since-compact opens on what replaced the history. A failed compaction removed nothing and is not a seam, so it produces no marker at all.

Recency is the event log's mtime, not workspace.yaml's updated_at: the yaml is rewritten when metadata changes, so it lags a session that is still appending. A test pins that. The rest of the mapping — raw versus transformed user text, empty content on tool-only turns, per-turn model — is stated in the package doc.

Both handoff halves are native, so neither direction needs the refusal text zcode and deepseek carry: copilot --resume=<id> forks and copilot -i <prompt> seeds. The id is inline because a bare --resume opens the session picker and would swallow a separated argument. Both spellings are pinned in the CLI command-contract tests.

Copilot exports COPILOT_AGENT_SESSION_ID into every shell it spawns, so a catchup run from inside a session resolves that session instead of guessing by recency — the same signal Claude Code provides.

workspace.yaml is a flat map of scalars, parsed in a dozen lines rather than bought with a YAML dependency. go.mod is unchanged.

Reviewing

Three commits, worth reading in order: the first adds the provider, the second corrects three judgments the shipped schema disproved, the third deletes the state that correction made redundant. Everything load-bearing is in internal/copilot/copilot.go, whose package doc states the format and every call above; the rest is the ten-line wiring each provider needs, plus two doc lists.

What the later commits changed, so nobody reads the first as final:

  • sub-agent turns went from "a subagent.* type the reader can skip by name" to agentId on the ordinary types — the first pass let their text into transcripts
  • compaction went from a bare marker on every event to a summary-carrying marker on successful ones only — the first pass let a failed compaction become a --since-compact cut point
  • the session id stopped being read from workspace.yaml, where a listing could report an id Resolve would then reject — and with one source of truth left, dirInfo no longer carries a copy of it

Verified

Against real sessions from @github/copilot 1.0.80:

$ catchup copilot --list
SESSION   UPDATED TITLE
copilot/1 15h ago Run: printenv | grep -i copilot ; then report the exact output
copilot/2 18h ago Reply with exactly: hello from copilot

$ catchup copilot -i                                    → session: 6a2ac61d…  (newest)
$ COPILOT_AGENT_SESSION_ID=816a9dd1… catchup copilot -i → session: 816a9dd1…  (the live one)

Also on real logs: read, -q, --last N, --id, --json, --dir, cross-agent listing, install-skill copilot, and both fork directions with a PATH shim so nothing launched.

Compaction and sub-agents were exercised on a synthetic log built to the shipped schema — --since-compact cuts at the successful compaction, prints its summary, and drops the sub-agent turn — because no local session compacted or spawned a sub-agent. Both behaviors are mutation-checked: deleting the agentId skip fails TestReadSkipsEverythingButConversation (7 entries, want 5), deleting the success guard fails TestCompactionMarkers.

go build ./..., go vet ./..., and go test ./... pass.

Copilot keeps one directory per session under $COPILOT_HOME/session-state:
workspace.yaml for the metadata, events.jsonl for the append-only log.
Resuming appends to the same log, so a session is always one file.

On the timeline: user.message content (the raw text, not the
transformedContent copy that carries injected datetime and system-reminder
context) and assistant.message content, which is empty on turns that only
request tools. Sub-agent traffic arrives under subagent.* types and stays
off the timeline. Recency comes from the log's mtime, not workspace.yaml's
updated_at, which lags a session that is still appending.

Both handoff halves are native: `copilot --resume=<id>` forks, `copilot -i
<prompt>` seeds. The id goes inline because a bare --resume opens the
session picker and would swallow a separated argument.

Copilot also exports COPILOT_AGENT_SESSION_ID into every shell it spawns,
so a catchup run from inside a session resolves that session instead of
guessing by recency.

Fixes #8
The CLI ships schemas/session-events.schema.json, which settles three things
the first pass guessed at.

Sub-agent turns are not a separate event type. They reuse user.message and
assistant.message and are marked by the envelope's agentId, "absent for
events from the root/main agent" — so a sub-agent's prompts and answers were
landing in the transcript, and its model could overwrite the session's.
Events carrying an agentId are now skipped.

A session.compaction_complete carries success, and on success the
summaryContent that replaced the history. A failed compaction removed
nothing and is not a seam, so it no longer produces a marker that
--since-compact would cut on; a successful one now carries its summary
instead of being bare.

The session id is the directory name alone. It is what --resume takes and
what workspace.yaml repeats, and reading it from the yaml meant a listed id
was not guaranteed to resolve.

Tests follow the schema rather than the earlier guess, and cover the failed
compaction, the sub-agent turns, the listed-id round trip, and the Copilot
argv in the CLI command-contract tests.
Once the directory name became the canonical session id, dirInfo's id field
was a second copy of filepath.Base(path) — state that can only ever disagree
with the path it came from, and Read was seeding it from a caller-supplied
Ref. The two uses derive it instead.

Also names the shipped event schema, not just the live install, as where the
format reference comes from, and says in the package doc that the directory
name is the id --resume takes.
@wilbeibi
wilbeibi merged commit 159dbe1 into main Aug 26, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support handoff from Copilot to other Agent

1 participant