diff --git a/README.md b/README.md index 990065c..483748d 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Use it whenever you don't want to explain the whole job again — you hit an agent's usage limit, switch tools mid-task, pick up older work, or want a clean record of what happened. -Works with **Claude Code**, **Codex**, **Cursor**, **Cline**, **Kimi**, **Antigravity**, **OpenCode**, **Pi Agent**, **ZCode**, and **DeepSeek Harness**. +Works with **Claude Code**, **Codex**, **Copilot CLI**, **Cursor**, **Cline**, **Kimi**, **Antigravity**, **OpenCode**, **Pi Agent**, **ZCode**, and **DeepSeek Harness**.
@@ -54,7 +54,7 @@ herdr plugin install wilbeibi/herdr-catchup ## Usage -Agents: `claude` · `codex` · `cursor` · `cline` · `kimi` · `agy` (Antigravity) · `opencode` · `pi-agent` +Agents: `claude` · `codex` · `copilot` · `cursor` · `cline` · `kimi` · `agy` (Antigravity) · `opencode` · `pi-agent` · `zcode` · `deepseek` (dsh) Omit `` and catchup uses whichever agent has the newest session in this directory. Inside a live session, that's usually the session you're in. diff --git a/SKILL.md b/SKILL.md index 211971c..33530fd 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,6 +1,6 @@ --- name: catchup -description: Recovers prior coding-agent session context by running `catchup --since-compact`, which extracts a clean summary of a previous Codex, Claude Code, Antigravity, Cline, Cursor, DeepSeek Harness, Kimi, OpenCode, Pi Agent, or ZCode session. Use when the user says "catch up", "what did the last session do", "get me up to speed", "I switched agents", or asks to recover/summarize a previous session before continuing. Do NOT use for the current conversation, git history, or any non-agent log. +description: Recovers prior coding-agent session context by running `catchup --since-compact`, which extracts a clean summary of a previous Codex, Claude Code, Antigravity, Cline, Copilot CLI, Cursor, DeepSeek Harness, Kimi, OpenCode, Pi Agent, or ZCode session. Use when the user says "catch up", "what did the last session do", "get me up to speed", "I switched agents", or asks to recover/summarize a previous session before continuing. Do NOT use for the current conversation, git history, or any non-agent log. --- # catchup @@ -24,7 +24,7 @@ catchup fork # native resume, full state catchup fork --into # seed a different agent with the transcript ``` -Agents: `codex`, `claude`, `agy` (Antigravity), `cline`, `cursor`, `deepseek` (dsh), `kimi`, `opencode`, `pi-agent`, `zcode`. +Agents: `codex`, `claude`, `agy` (Antigravity), `cline`, `copilot`, `cursor`, `deepseek` (dsh), `kimi`, `opencode`, `pi-agent`, `zcode`. ## Operation diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 136106a..c1d306b 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -25,6 +25,7 @@ import ( "github.com/wilbeibi/catchup/internal/claude" "github.com/wilbeibi/catchup/internal/cline" "github.com/wilbeibi/catchup/internal/codex" + "github.com/wilbeibi/catchup/internal/copilot" "github.com/wilbeibi/catchup/internal/cursor" "github.com/wilbeibi/catchup/internal/deepseek" "github.com/wilbeibi/catchup/internal/kimi" @@ -40,7 +41,8 @@ const helpText = `Usage: catchup [agent[/]] [flags] read a past ses catchup fork --into --from catchup install-skill [agent] -Agents: codex, claude, agy (Antigravity), cline, cursor, deepseek (dsh), kimi, opencode, pi-agent, zcode +Agents: codex, claude, agy (Antigravity), cline, copilot, cursor, deepseek (dsh), +kimi, opencode, pi-agent, zcode Omit the agent to use whichever has the newest session here. Bare ` + "`catchup`" + ` prints that session in full, as Markdown. The flags refine three things: which session, how much of it, and as what. @@ -317,6 +319,7 @@ func providerNames() []string { session.ProviderClaude, session.ProviderAgy, session.ProviderCline, + session.ProviderCopilot, session.ProviderCursor, session.ProviderDeepSeek, session.ProviderKimi, @@ -338,6 +341,8 @@ func selectProvider(name string) (session.Provider, error) { return agy.New(), nil case session.ProviderCline: return cline.New(), nil + case session.ProviderCopilot: + return copilot.New(), nil case session.ProviderCursor: return cursor.New(), nil case session.ProviderDeepSeek: @@ -363,7 +368,7 @@ func selectProvider(name string) (session.Provider, error) { if name == "dsh" { return nil, fmt.Errorf(`unknown agent "dsh"; DeepSeek Harness's agent name is deepseek`) } - return nil, fmt.Errorf("unknown agent %q (want codex, claude, agy, cline, cursor, deepseek, kimi, opencode, pi-agent, or zcode); run catchup --help", name) + return nil, fmt.Errorf("unknown agent %q (want codex, claude, agy, cline, copilot, cursor, deepseek, kimi, opencode, pi-agent, or zcode); run catchup --help", name) } } @@ -805,6 +810,10 @@ func intoCommand(target, prompt, model string) (string, []string, error) { return "cline", append(modelArgs("--model", model), "-i", prompt), nil case session.ProviderCursor: return "cursor-agent", append(modelArgs("--model", model), prompt), nil + case session.ProviderCopilot: + // -i starts interactive and auto-executes the prompt; a bare -p is + // non-interactive and exits when the answer lands. + return "copilot", append(modelArgs("--model", model), "-i", prompt), nil case session.ProviderKimi: // Kimi rejects positional arguments and its -p flag is // non-interactive print mode, so there is no way to start an @@ -896,6 +905,13 @@ func forkCommand(src session.Source, model string) (string, []string, error) { // Cline has no fork, and a bare --id only prints a session summary // and exits; -i opens the TUI resumed on the session. return "cline", append([]string{"-i", "--id", src.Ref.SessionID}, modelArgs("--model", model)...), nil + case session.ProviderCopilot: + if src.Ref.SessionID == "" { + return "", nil, fmt.Errorf("fork copilot: missing session id") + } + // Copilot has no fork; --resume is its native resume, and it takes + // the id inline (a bare --resume opens the session picker). + return "copilot", append([]string{"--resume=" + src.Ref.SessionID}, modelArgs("--model", model)...), nil case session.ProviderCursor: if src.Ref.SessionID == "" { return "", nil, fmt.Errorf("fork cursor: missing session id") diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index cc4b180..96aba10 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -490,6 +490,10 @@ func TestForkCommand(t *testing.T) { {"opencode", session.Source{Ref: session.Ref{Provider: session.ProviderOpenCode, SessionID: "o1"}}, "", "opencode --session o1 --fork"}, {"pi path", session.Source{Ref: session.Ref{Provider: session.ProviderPiAgent, SessionID: "p1"}, Path: "/tmp/pi.jsonl"}, "", "pi --fork /tmp/pi.jsonl"}, {"codex with model", session.Source{Ref: session.Ref{Provider: session.ProviderCodex, SessionID: "c1"}}, "gpt-5.6", "codex fork c1 -m gpt-5.6"}, + // The id is inline: a bare --resume opens Copilot's session picker, + // which would swallow a separated id as an unrelated argument. + {"copilot", session.Source{Ref: session.Ref{Provider: session.ProviderCopilot, SessionID: "gh1"}}, "", "copilot --resume=gh1"}, + {"copilot with model", session.Source{Ref: session.Ref{Provider: session.ProviderCopilot, SessionID: "gh1"}}, "gpt-5.4", "copilot --resume=gh1 --model gpt-5.4"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -715,6 +719,7 @@ func TestIntoCommandModelPlacement(t *testing.T) { {session.ProviderClaude, "--model M PROMPT"}, {session.ProviderAgy, "--model M -i PROMPT"}, {session.ProviderOpenCode, "--model M --prompt PROMPT"}, + {session.ProviderCopilot, "--model M -i PROMPT"}, } for _, tt := range tests { t.Run(tt.target, func(t *testing.T) { diff --git a/internal/copilot/copilot.go b/internal/copilot/copilot.go new file mode 100644 index 0000000..aa8fc6f --- /dev/null +++ b/internal/copilot/copilot.go @@ -0,0 +1,352 @@ +// Package copilot implements session.Provider over GitHub Copilot CLI +// history: one directory per session under $COPILOT_HOME/session-state +// (default ~/.copilot/session-state). +// +// Format reference, from a live install (@github/copilot 1.0.80) and the +// schemas/session-events.schema.json it ships: +// +// session-state//workspace.yaml holds the session's metadata as a +// flat key/value map (id, cwd, name, created_at, updated_at, plus git_root, +// repository and branch when the session started inside a repository), and +// session-state//events.jsonl is the append-only event log. Resuming +// a session appends to the same log rather than starting a new directory, +// so one session is always one file. The directory name is the session id: +// the value --resume takes, and the id every listing reports. +// +// Every event is {type, id, parentId, timestamp, agentId, data} with an +// RFC 3339 timestamp. Visible on the timeline: user/message content +// (data.content is what the human typed; data.transformedContent is the same +// text wrapped in injected datetime and system-reminder context, and is not +// conversation) and assistant/message content, which is empty on the turns +// that only carry tool requests. Everything else — system.message, tool.*, +// assistant.turn_*, session.usage_checkpoint, and the session lifecycle +// events — is bookkeeping. +// +// Sub-agent traffic reuses those same two types and is told apart by the +// envelope's agentId, which the schema documents as "absent for events from +// the root/main agent". A sub-agent's +// prompts and answers are the parent turn's tool plumbing, not conversation, +// so any event carrying an agentId is skipped — including for the model, +// which a sub-agent routed to another model would otherwise overwrite. +// +// A session.compaction_complete carries success and, when the compaction +// succeeded, summaryContent: the LLM-written summary that replaced the +// history. It becomes the compaction marker's text, so --since-compact opens +// on what survived rather than on a bare seam. A failed compaction removed +// nothing and is not a seam, so it produces no marker. +// +// The model is read from each root assistant message's data.model, last +// writer wins: sessions run with --model auto are routed per turn, so the last +// answer's model is the one that produced the tail of the transcript. +// +// Session recency comes from the event log's mtime, not workspace.yaml's +// updated_at: the yaml is rewritten when the session's metadata changes +// (naming, resume), so it lags a session that is still appending events. +// +// Unverified: no local session compacted or spawned a sub-agent, so the +// compaction and agentId handling follows the shipped schema rather than an +// observed log. Legacy sessions under history-session-state/ (pre-migration +// format) are not read; Copilot migrates one into session-state/ the first +// time it is resumed. +package copilot + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/wilbeibi/catchup/internal/session" +) + +// Provider reads Copilot CLI session state. It is stateless; every call +// re-reads the files, so a concurrently writing copilot is never blocked. +type Provider struct{} + +// New returns a GitHub Copilot CLI provider. +func New() *Provider { return &Provider{} } + +var _ session.Provider = (*Provider)(nil) + +const ( + eventsFile = "events.jsonl" + workspaceFile = "workspace.yaml" +) + +func (p *Provider) Resolve(ctx context.Context, roots session.Roots, id string) (session.Source, error) { + dirs, err := sessionDirs(roots.Copilot) + if err != nil { + return session.Source{}, err + } + if len(dirs) == 0 { + return session.Source{}, fmt.Errorf("copilot: no sessions found under %s", roots.Copilot) + } + if id != "" { + for _, d := range dirs { + if filepath.Base(d.path) == id { + return readMeta(d) + } + } + return session.Source{}, fmt.Errorf("copilot: no session with id %q", id) + } + return readMeta(dirs[0]) +} + +func (p *Provider) Read(ctx context.Context, src session.Source) (session.Thread, error) { + if src.Path == "" { + return session.Thread{}, errors.New("copilot: source has no path") + } + info, err := os.Stat(src.Path) + if err != nil { + return session.Thread{}, err + } + d := dirInfo{path: filepath.Dir(src.Path), mod: info.ModTime()} + return readThread(d, readWorkspace(d.path)) +} + +func (p *Provider) List(ctx context.Context, roots session.Roots, opts session.ListOptions) ([]session.Summary, error) { + dirs, err := sessionDirs(roots.Copilot) + if err != nil { + return nil, err + } + q := strings.ToLower(opts.Query) + limit := opts.EffectiveLimit() + out := make([]session.Summary, 0, limit) + for _, d := range dirs { + if len(out) >= limit { + break + } + // The directory filter is answered from workspace.yaml alone, so a + // session in another directory never costs an event-log parse. The + // parsed map is handed on, so the file is read once either way. + meta := readWorkspace(d.path) + if opts.Cwd != "" && meta["cwd"] != opts.Cwd { + continue + } + t, err := readThread(d, meta) + if err != nil || len(t.Entries) == 0 { + continue + } + if q != "" && !strings.Contains(strings.ToLower(t.VisibleText()), q) { + continue + } + out = append(out, t.Summary()) + } + for i := range out { + out[i].Rank = i + 1 + } + return out, nil +} + +// --- directory enumeration -------------------------------------------------- + +// dirInfo is one session directory. The id is not carried alongside it: the +// directory's base name is the session id, so a second copy could only ever +// disagree with the path it came from. +type dirInfo struct { + path string + mod time.Time +} + +// sessionDirs returns every session directory under /session-state that +// holds an event log, newest first. The directory's base name is the session +// id — the value --resume takes, and the id workspace.yaml repeats — so it is +// the one Resolve matches and List reports, and a session whose yaml is +// missing or truncated is still selectable. +func sessionDirs(root string) ([]dirInfo, error) { + base := filepath.Join(root, "session-state") + entries, err := os.ReadDir(base) + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + var dirs []dirInfo + for _, e := range entries { + if !e.IsDir() { + continue + } + dir := filepath.Join(base, e.Name()) + info, err := os.Stat(filepath.Join(dir, eventsFile)) + if err != nil { + continue + } + dirs = append(dirs, dirInfo{path: dir, mod: info.ModTime()}) + } + sort.Slice(dirs, func(i, j int) bool { return dirs[i].mod.After(dirs[j].mod) }) + return dirs, nil +} + +// readMeta delegates instead of describing the session from workspace.yaml +// alone: the yaml has no model, and a metadata-only view that omits the model +// would be poorer than every other provider's. Event logs are small (a long +// session stays under a few MB). +func readMeta(d dirInfo) (session.Source, error) { + t, err := readThread(d, readWorkspace(d.path)) + return t.Source, err +} + +// readSource describes a session from its already-parsed workspace.yaml — +// everything a listing row needs except the timeline itself. +func readSource(d dirInfo, meta map[string]string) session.Source { + src := session.Source{ + Ref: session.Ref{Provider: session.ProviderCopilot, SessionID: filepath.Base(d.path)}, + Path: filepath.Join(d.path, eventsFile), + UpdatedAt: d.mod, + Metadata: map[string]string{}, + } + if cwd := meta["cwd"]; cwd != "" { + src.Metadata["cwd"] = cwd + } + if name := meta["name"]; name != "" { + src.Metadata["title"] = name + } else if cwd := meta["cwd"]; cwd != "" { + src.Metadata["title"] = filepath.Base(cwd) + } + return src +} + +// readWorkspace parses workspace.yaml. The file is a flat map of scalars +// written by Copilot itself — no nesting, no lists — so a full YAML parser +// would be a dependency bought for a dozen lines of text. It is read whole +// because it is small by construction and a session's whole metadata is +// wanted at once; a missing or unreadable file yields an empty map, leaving +// the session readable from its event log alone. +func readWorkspace(dir string) map[string]string { + raw, err := os.ReadFile(filepath.Join(dir, workspaceFile)) + if err != nil { + return map[string]string{} + } + meta := map[string]string{} + for _, line := range strings.Split(string(raw), "\n") { + // Indented lines would be nested values; Copilot writes none, and + // guessing at one's meaning is worse than ignoring it. + if line == "" || strings.HasPrefix(line, " ") || strings.HasPrefix(line, "#") { + continue + } + key, val, ok := strings.Cut(line, ":") + if !ok { + continue + } + meta[strings.TrimSpace(key)] = unquote(strings.TrimSpace(val)) + } + return meta +} + +// unquote strips the one quoting form Copilot's writer emits: a single-quoted +// scalar, which escapes its own quote by doubling it. Bare scalars pass +// through untouched. +func unquote(v string) string { + if len(v) >= 2 && v[0] == '\'' && v[len(v)-1] == '\'' { + return strings.ReplaceAll(v[1:len(v)-1], "''", "'") + } + return v +} + +// --- parsing ---------------------------------------------------------------- + +// The envelope decodes only the fields every event carries; data is kept raw +// and handed to the per-type shapes in applyEvent, because unrelated events +// reuse field names with different shapes and one mismatch would fail the +// whole event's decode. +type cpEvent struct { + Type string `json:"type"` + Timestamp string `json:"timestamp"` + AgentID string `json:"agentId"` + Data json.RawMessage `json:"data"` +} + +type cpUserMessage struct { + Content string `json:"content"` +} + +type cpAssistantMessage struct { + Content string `json:"content"` + Model string `json:"model"` +} + +type cpCompaction struct { + Success bool `json:"success"` + Summary string `json:"summaryContent"` +} + +func readThread(d dirInfo, meta map[string]string) (session.Thread, error) { + src := readSource(d, meta) + f, err := os.Open(src.Path) + if err != nil { + return session.Thread{}, err + } + defer f.Close() + + var entries []session.Entry + var warnings []string + dec := json.NewDecoder(f) + for dec.More() { + var ev cpEvent + if err := dec.Decode(&ev); err != nil { + // A killed writer can leave a torn final line; keep the prefix. + warnings = append(warnings, "stopped reading at a malformed record") + break + } + applyEvent(&src, &entries, ev) + } + return session.Thread{Source: src, Entries: entries, Warnings: warnings}, nil +} + +// applyEvent folds one event into the source metadata or the timeline. An +// unparseable or empty payload makes the event contribute nothing rather than +// fail the read. +func applyEvent(src *session.Source, entries *[]session.Entry, ev cpEvent) { + if ev.AgentID != "" { + return // a sub-agent's own turns: the parent's tool plumbing + } + switch ev.Type { + case "user.message": + var d cpUserMessage + if json.Unmarshal(ev.Data, &d) != nil || d.Content == "" { + return + } + *entries = append(*entries, session.Entry{ + Kind: session.KindMessage, Role: session.RoleUser, + Text: d.Content, Time: parseTime(ev.Timestamp), + }) + case "assistant.message": + var d cpAssistantMessage + if json.Unmarshal(ev.Data, &d) != nil { + return + } + if d.Model != "" { + src.Metadata["model"] = d.Model + } + if d.Content == "" { + return // a turn that only requested tools + } + *entries = append(*entries, session.Entry{ + Kind: session.KindMessage, Role: session.RoleAssistant, + Text: d.Content, Time: parseTime(ev.Timestamp), + }) + case "session.compaction_complete": + var d cpCompaction + if json.Unmarshal(ev.Data, &d) != nil || !d.Success { + return // a failed compaction removed nothing: not a seam + } + *entries = append(*entries, session.Entry{ + Kind: session.KindCompact, Text: d.Summary, Time: parseTime(ev.Timestamp), + }) + } +} + +func parseTime(s string) time.Time { + t, err := time.Parse(time.RFC3339, s) + if err != nil { + return time.Time{} + } + return t +} diff --git a/internal/copilot/copilot_test.go b/internal/copilot/copilot_test.go new file mode 100644 index 0000000..74fca3c --- /dev/null +++ b/internal/copilot/copilot_test.go @@ -0,0 +1,302 @@ +package copilot + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/wilbeibi/catchup/internal/session" +) + +// events mirrors a real Copilot CLI log (@github/copilot 1.0.80), with the +// event shapes taken from the schemas/session-events.schema.json that ships +// with the CLI. It carries one of each kind the provider must handle: the +// lifecycle rows, the system prompt, a tool-only assistant turn, the tool +// plumbing around it, and a sub-agent's own messages — which reuse the +// ordinary message types and are marked by the envelope's agentId — plus a +// failed compaction, a successful one, and the human turns and answers that +// are the actual conversation. +const events = `{"type":"session.start","id":"e1","timestamp":"2026-08-24T15:07:45.905Z","data":{"sessionId":"816a9dd1","version":1,"producer":"copilot-agent","context":{"cwd":"/home/u/src/catchup"}}} +{"type":"session.model_change","id":"e2","timestamp":"2026-08-24T15:07:47.149Z","data":{"newModel":"auto","reasoningEffort":null}} +{"type":"session.auto_mode_resolved","id":"e3","timestamp":"2026-08-24T15:07:47.958Z","data":{"chosenModel":"claude-haiku-4.5","routingMethod":"hydra"}} +{"type":"system.message","id":"e4","timestamp":"2026-08-24T15:07:47.999Z","data":{"role":"system","content":"You are the GitHub Copilot CLI."}} +{"type":"user.message","id":"e5","timestamp":"2026-08-24T15:07:48.023Z","data":{"content":"support copilot","transformedContent":"2026-08-24T08:07:48.022-07:00\n\nsupport copilot\n\ninjected","attachments":[],"delivery":"idle"}} +{"type":"assistant.turn_start","id":"e6","timestamp":"2026-08-24T15:07:48.070Z","data":{"turnId":"0"}} +{"type":"assistant.message","id":"e7","timestamp":"2026-08-24T15:07:49.558Z","data":{"messageId":"m1","model":"claude-haiku-4.5","content":"I will read the log.","toolRequests":[],"turnId":"0"}} +{"type":"assistant.message","id":"e8","timestamp":"2026-08-24T15:07:50.100Z","data":{"messageId":"m2","model":"claude-haiku-4.5","content":"","toolRequests":[{"toolCallId":"t1","name":"bash","arguments":{"command":"ls"}}],"turnId":"0"}} +{"type":"tool.execution_start","id":"e9","timestamp":"2026-08-24T15:07:50.200Z","data":{"toolCallId":"t1","name":"bash"}} +{"type":"tool.execution_complete","id":"e10","timestamp":"2026-08-24T15:07:50.900Z","data":{"toolCallId":"t1","result":"a.txt"}} +{"type":"subagent.started","id":"e11","timestamp":"2026-08-24T15:07:50.950Z","data":{"toolCallId":"t1","agentName":"explore"}} +{"type":"user.message","id":"e12","agentId":"agent-7","timestamp":"2026-08-24T15:07:51.000Z","data":{"content":"sub-agent instructions"}} +{"type":"assistant.message","id":"e13","agentId":"agent-7","timestamp":"2026-08-24T15:07:51.500Z","data":{"messageId":"m9","model":"gpt-5-mini","content":"sub-agent answer"}} +{"type":"assistant.turn_end","id":"e13","timestamp":"2026-08-24T15:07:52.000Z","data":{"turnId":"0"}} +{"type":"session.compaction_complete","id":"e15","timestamp":"2026-08-24T15:07:59.000Z","data":{"success":false,"error":"model unavailable","statusCode":503}} +{"type":"session.compaction_complete","id":"e16","timestamp":"2026-08-24T15:08:00.000Z","data":{"success":true,"summaryContent":"The user asked for Copilot support; the log format is settled.","preCompactionTokens":180000,"messagesRemoved":42,"compactionTokensUsed":{"inputTokens":1234,"outputTokens":56}}} +{"type":"session.shutdown","id":"e15","timestamp":"2026-08-24T15:08:10.000Z","data":{}} +{"type":"session.resume","id":"e16","timestamp":"2026-08-24T15:08:35.352Z","data":{"eventCount":15,"context":{"cwd":"/home/u/src/catchup"}}} +{"type":"user.message","id":"e17","timestamp":"2026-08-24T15:08:37.037Z","data":{"content":"finish it","transformedContent":"wrapped"}} +{"type":"assistant.message","id":"e18","timestamp":"2026-08-24T15:08:40.397Z","data":{"messageId":"m3","model":"gpt-5.4","content":"done","toolRequests":[]}} +` + +const sessionID = "816a9dd1-c2ef-4b36-99a8-b503320856cc" + +const workspace = `id: ` + sessionID + ` +cwd: /home/u/src/catchup +git_root: /home/u/src/catchup +repository: wilbeibi/catchup +branch: main +client_name: github/cli +name: 'Reply with exactly: hello' +user_named: false +summary_count: 0 +created_at: 2026-08-24T15:07:45.818Z +updated_at: 2026-08-24T15:07:45.850Z +` + +func ts(t *testing.T, s string) time.Time { + t.Helper() + v, err := time.Parse(time.RFC3339, s) + if err != nil { + t.Fatal(err) + } + return v +} + +func wantEntries(t *testing.T) []session.Entry { + return []session.Entry{ + {Kind: session.KindMessage, Role: session.RoleUser, Text: "support copilot", Time: ts(t, "2026-08-24T15:07:48.023Z")}, + {Kind: session.KindMessage, Role: session.RoleAssistant, Text: "I will read the log.", Time: ts(t, "2026-08-24T15:07:49.558Z")}, + {Kind: session.KindCompact, Text: "The user asked for Copilot support; the log format is settled.", Time: ts(t, "2026-08-24T15:08:00.000Z")}, + {Kind: session.KindMessage, Role: session.RoleUser, Text: "finish it", Time: ts(t, "2026-08-24T15:08:37.037Z")}, + {Kind: session.KindMessage, Role: session.RoleAssistant, Text: "done", Time: ts(t, "2026-08-24T15:08:40.397Z")}, + } +} + +// writeSession lays out one session directory. A session whose workspace.yaml +// is empty stands for one killed before its metadata was written. +func writeSession(t *testing.T, root, id, ws, log string, mod time.Time) string { + t.Helper() + dir := filepath.Join(root, "session-state", id) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if ws != "" { + if err := os.WriteFile(filepath.Join(dir, workspaceFile), []byte(ws), 0o644); err != nil { + t.Fatal(err) + } + } + path := filepath.Join(dir, eventsFile) + if err := os.WriteFile(path, []byte(log), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(path, mod, mod); err != nil { + t.Fatal(err) + } + return dir +} + +func rootsAt(dir string) session.Roots { return session.Roots{Copilot: dir} } + +func TestReadSkipsEverythingButConversation(t *testing.T) { + root := t.TempDir() + writeSession(t, root, sessionID, workspace, events, time.Now()) + + p := New() + src, err := p.Resolve(context.Background(), rootsAt(root), "") + if err != nil { + t.Fatal(err) + } + if src.Ref.Provider != session.ProviderCopilot { + t.Errorf("provider = %q", src.Ref.Provider) + } + if src.Ref.SessionID != sessionID { + t.Errorf("session id = %q", src.Ref.SessionID) + } + if src.Metadata["cwd"] != "/home/u/src/catchup" { + t.Errorf("cwd = %q", src.Metadata["cwd"]) + } + // A single-quoted scalar keeps the colon inside the title. + if src.Metadata["title"] != "Reply with exactly: hello" { + t.Errorf("title = %q", src.Metadata["title"]) + } + // Last writer wins: the session was routed to another model mid-way. + if src.Metadata["model"] != "gpt-5.4" { + t.Errorf("model = %q", src.Metadata["model"]) + } + + th, err := p.Read(context.Background(), src) + if err != nil { + t.Fatal(err) + } + got, want := th.Entries, wantEntries(t) + if len(got) != len(want) { + t.Fatalf("entries = %d, want %d: %+v", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("entry %d =\n%+v\nwant\n%+v", i, got[i], want[i]) + } + } + if len(th.Warnings) != 0 { + t.Errorf("warnings = %v, want none", th.Warnings) + } +} + +// A session killed mid-write leaves a torn final line. The prefix stays +// readable and the reader says what it dropped. +func TestReadTornFinalLine(t *testing.T) { + root := t.TempDir() + writeSession(t, root, "torn", workspace, events+`{"type":"user.message","data":{"cont`, time.Now()) + + p := New() + src, err := p.Resolve(context.Background(), rootsAt(root), "") + if err != nil { + t.Fatal(err) + } + th, err := p.Read(context.Background(), src) + if err != nil { + t.Fatal(err) + } + if len(th.Entries) != len(wantEntries(t)) { + t.Errorf("entries = %d, want %d", len(th.Entries), len(wantEntries(t))) + } + if len(th.Warnings) == 0 { + t.Error("no warning for the torn record") + } +} + +// Without workspace.yaml the directory name is the id and the timeline is +// still whole. +func TestReadWithoutWorkspaceFile(t *testing.T) { + root := t.TempDir() + writeSession(t, root, "no-yaml", "", events, time.Now()) + + p := New() + src, err := p.Resolve(context.Background(), rootsAt(root), "") + if err != nil { + t.Fatal(err) + } + if src.Ref.SessionID != "no-yaml" { + t.Errorf("session id = %q, want the directory name", src.Ref.SessionID) + } + th, err := p.Read(context.Background(), src) + if err != nil { + t.Fatal(err) + } + if len(th.Entries) != len(wantEntries(t)) { + t.Errorf("entries = %d, want %d", len(th.Entries), len(wantEntries(t))) + } +} + +// Recency is the event log's mtime, not workspace.yaml's updated_at: the yaml +// is written when metadata changes and lags a session that is still appending. +func TestListOrdersByEventLogMtime(t *testing.T) { + root := t.TempDir() + now := time.Now() + writeSession(t, root, "older", workspace, events, now.Add(-time.Hour)) + writeSession(t, root, "newer-id", strings.Replace(workspace, sessionID, "newer-id", 1), events, now) + + got, err := New().List(context.Background(), rootsAt(root), session.ListOptions{}) + if err != nil { + t.Fatal(err) + } + if len(got) != 2 { + t.Fatalf("rows = %d, want 2", len(got)) + } + if got[0].Ref.SessionID != "newer-id" || got[0].Rank != 1 { + t.Errorf("first row = %+v", got[0]) + } +} + +func TestListFiltersByCwdAndQuery(t *testing.T) { + root := t.TempDir() + now := time.Now() + writeSession(t, root, "here", workspace, events, now) + elsewhere := strings.Replace(workspace, "cwd: /home/u/src/catchup", "cwd: /home/u/src/other", 1) + writeSession(t, root, "there", elsewhere, events, now.Add(-time.Minute)) + + p := New() + rows, err := p.List(context.Background(), rootsAt(root), session.ListOptions{Cwd: "/home/u/src/other"}) + if err != nil { + t.Fatal(err) + } + if len(rows) != 1 || rows[0].Cwd != "/home/u/src/other" { + t.Fatalf("cwd filter kept %+v", rows) + } + + rows, err = p.List(context.Background(), rootsAt(root), session.ListOptions{Query: "sub-agent"}) + if err != nil { + t.Fatal(err) + } + if len(rows) != 0 { + t.Errorf("query matched sub-agent text that is not on the timeline: %+v", rows) + } +} + +// Every id a listing reports must select that session on the next run: the +// rank a user retypes resolves through this round trip. +func TestListedIDsResolve(t *testing.T) { + root := t.TempDir() + writeSession(t, root, sessionID, workspace, events, time.Now()) + + p := New() + rows, err := p.List(context.Background(), rootsAt(root), session.ListOptions{}) + if err != nil { + t.Fatal(err) + } + if len(rows) != 1 { + t.Fatalf("rows = %d, want 1", len(rows)) + } + src, err := p.Resolve(context.Background(), rootsAt(root), rows[0].Ref.SessionID) + if err != nil { + t.Fatalf("resolving the listed id %q: %v", rows[0].Ref.SessionID, err) + } + if src.Ref.SessionID != rows[0].Ref.SessionID { + t.Errorf("resolved %q, want %q", src.Ref.SessionID, rows[0].Ref.SessionID) + } +} + +// A compaction that failed removed nothing, so it is not a seam --since-compact +// may cut on; only the successful one is a marker, and it carries the summary +// that replaced the history. +func TestCompactionMarkers(t *testing.T) { + root := t.TempDir() + writeSession(t, root, sessionID, workspace, events, time.Now()) + + p := New() + src, err := p.Resolve(context.Background(), rootsAt(root), "") + if err != nil { + t.Fatal(err) + } + th, err := p.Read(context.Background(), src) + if err != nil { + t.Fatal(err) + } + var marks []session.Entry + for _, e := range th.Entries { + if e.Kind == session.KindCompact { + marks = append(marks, e) + } + } + if len(marks) != 1 { + t.Fatalf("compaction markers = %d, want 1 (the failed one is not a seam): %+v", len(marks), marks) + } + if !strings.Contains(marks[0].Text, "the log format is settled") { + t.Errorf("marker text = %q, want the summaryContent", marks[0].Text) + } +} + +func TestResolveErrors(t *testing.T) { + root := t.TempDir() + p := New() + if _, err := p.Resolve(context.Background(), rootsAt(root), ""); err == nil { + t.Error("empty root resolved without error") + } + writeSession(t, root, sessionID, workspace, events, time.Now()) + if _, err := p.Resolve(context.Background(), rootsAt(root), "nope"); err == nil { + t.Error("unknown id resolved without error") + } +} diff --git a/internal/session/roots.go b/internal/session/roots.go index dda4221..7d84d70 100644 --- a/internal/session/roots.go +++ b/internal/session/roots.go @@ -16,6 +16,7 @@ import "path/filepath" // Cursor : $CURSOR_CONFIG_DIR else $XDG_CONFIG_HOME/cursor else /.cursor // ZCode : $ZCODE_HOME else /.zcode/cli/db (the dir holding db.sqlite) // DeepSeek : $DSH_HOME else /.dsh +// Copilot : $COPILOT_HOME else /.copilot // // getenv and home are passed in rather than read from the os package so that // root resolution is a pure function and can be tested without touching the @@ -78,7 +79,13 @@ func ResolveRoots(getenv func(string) string, home string) Roots { deepseek = filepath.Join(home, ".dsh") } - return Roots{Codex: codex, Claude: claude, Agy: agy, OpenCode: opencode, PiAgent: piAgent, Kimi: kimi, Cline: cline, Cursor: cursor, ZCode: zcode, DeepSeek: deepseek} + // Copilot CLI keeps one directory per session under /session-state. + copilot := getenv("COPILOT_HOME") + if copilot == "" { + copilot = filepath.Join(home, ".copilot") + } + + return Roots{Codex: codex, Claude: claude, Agy: agy, OpenCode: opencode, PiAgent: piAgent, Kimi: kimi, Cline: cline, Cursor: cursor, ZCode: zcode, DeepSeek: deepseek, Copilot: copilot} } // ResolveSkillDirs returns each provider's global Agent Skills directory, @@ -103,6 +110,8 @@ func ResolveRoots(getenv func(string) string, home string) Roots { // Cursor : roots.Cursor/skills (respects $CURSOR_CONFIG_DIR) // ZCode : /.agents/skills (shares Codex's entry: ZCode // discovers ~/.agents/skills, so the same SKILL.md serves both) +// Copilot : roots.Copilot/skills (respects $COPILOT_HOME; Copilot +// also discovers ~/.agents/skills — Codex's entry, same reasoning) // DeepSeek : roots.DeepSeek/skills (respects $DSH_HOME; dsh also // discovers ~/.agents/skills — Codex's entry, same reasoning) func ResolveSkillDirs(roots Roots, home string) map[string]string { @@ -117,14 +126,16 @@ func ResolveSkillDirs(roots Roots, home string) map[string]string { ProviderCursor: filepath.Join(roots.Cursor, "skills"), ProviderZCode: filepath.Join(home, ".agents", "skills"), ProviderDeepSeek: filepath.Join(roots.DeepSeek, "skills"), + ProviderCopilot: filepath.Join(roots.Copilot, "skills"), } } // ResolveCurrent reports the session each provider says we are running inside, -// keyed by provider name. Only Claude Code injects such a signal today -// ($CLAUDE_CODE_SESSION_ID, set in every shell it spawns); Codex and OpenCode -// spawn shells indistinguishable from a plain terminal, so they contribute -// nothing. A provider absent from the map (or mapped to "") has no in-band +// keyed by provider name. Two agents inject such a signal into every shell +// they spawn: Claude Code ($CLAUDE_CODE_SESSION_ID) and Copilot CLI +// ($COPILOT_AGENT_SESSION_ID, whose value is the id --resume takes); Codex and +// OpenCode spawn shells indistinguishable from a plain terminal, so they +// contribute nothing. A provider absent from the map (or mapped to "") has no in-band // current session, and the caller falls back to the newest session in the // working directory. // @@ -135,5 +146,8 @@ func ResolveCurrent(getenv func(string) string) map[string]string { if id := getenv("CLAUDE_CODE_SESSION_ID"); id != "" { current[ProviderClaude] = id } + if id := getenv("COPILOT_AGENT_SESSION_ID"); id != "" { + current[ProviderCopilot] = id + } return current } diff --git a/internal/session/roots_test.go b/internal/session/roots_test.go index 0a39502..bc49776 100644 --- a/internal/session/roots_test.go +++ b/internal/session/roots_test.go @@ -12,7 +12,7 @@ import ( var allProviders = []string{ ProviderCodex, ProviderClaude, ProviderAgy, ProviderOpenCode, ProviderPiAgent, ProviderKimi, ProviderCline, ProviderCursor, - ProviderZCode, ProviderDeepSeek, + ProviderZCode, ProviderDeepSeek, ProviderCopilot, } // noEnv is the environment of a machine that overrides nothing. @@ -41,6 +41,7 @@ func TestResolveRootsDefaults(t *testing.T) { Cursor: filepath.Join(home, ".cursor"), ZCode: filepath.Join(home, ".zcode", "cli", "db"), DeepSeek: filepath.Join(home, ".dsh"), + Copilot: filepath.Join(home, ".copilot"), } if got != want { t.Fatalf("roots =\n%+v\nwant\n%+v", got, want) @@ -58,6 +59,7 @@ func TestResolveRootsOverrides(t *testing.T) { {"CODEX_HOME", map[string]string{"CODEX_HOME": dir}, func(r Roots) string { return r.Codex }, dir}, {"CLAUDE_CONFIG_DIR", map[string]string{"CLAUDE_CONFIG_DIR": dir}, func(r Roots) string { return r.Claude }, dir}, {"PI_CODING_AGENT_DIR", map[string]string{"PI_CODING_AGENT_DIR": dir}, func(r Roots) string { return r.PiAgent }, dir}, + {"COPILOT_HOME", map[string]string{"COPILOT_HOME": dir}, func(r Roots) string { return r.Copilot }, dir}, {"KIMI_CODE_HOME", map[string]string{"KIMI_CODE_HOME": dir}, func(r Roots) string { return r.Kimi }, dir}, {"CLINE_DIR", map[string]string{"CLINE_DIR": dir}, func(r Roots) string { return r.Cline }, dir}, {"ZCODE_HOME", map[string]string{"ZCODE_HOME": dir}, func(r Roots) string { return r.ZCode }, dir}, @@ -151,4 +153,8 @@ func TestResolveCurrent(t *testing.T) { if len(got) != 1 || got[ProviderClaude] != "sess-1" { t.Fatalf("current = %+v", got) } + got = ResolveCurrent(envFrom(map[string]string{"COPILOT_AGENT_SESSION_ID": "sess-2"})) + if len(got) != 1 || got[ProviderCopilot] != "sess-2" { + t.Fatalf("current = %+v", got) + } } diff --git a/internal/session/session.go b/internal/session/session.go index 8cf55bc..8799b46 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -34,6 +34,7 @@ const ( ProviderCursor = "cursor" // Cursor CLI (cursor-agent) ProviderZCode = "zcode" // ZCode (Z.ai) desktop agent ProviderDeepSeek = "deepseek" // DeepSeek Harness (dsh) + ProviderCopilot = "copilot" // GitHub Copilot CLI ) // Entry kinds and message roles. Providers normalize their own wire formats @@ -84,6 +85,7 @@ type Roots struct { Cursor string ZCode string DeepSeek string + Copilot string } // Source is a located session: enough to read it and to describe it in a