From d6e9682cc3e5efd2f425b6a3f05971267b23ddc1 Mon Sep 17 00:00:00 2001 From: Greggory Rothmeier Date: Mon, 2 Mar 2026 14:19:23 -0500 Subject: [PATCH 1/3] Add built-in support for GitHub Copilot CLI agent Add Copilot CLI as a supported agent alongside Claude, Codex, and OpenCode. This includes: - SessionSource: new .copilotCli case with icon and label - SessionIndex: new copilotCli dictionary for index caching - Session scanning: discovers sessions from ~/.copilot/session-state/ - Session parsing: reads workspace.yaml for metadata and events.jsonl for timestamps, user message counts, and snippets - Agent wrapper: shell script that emits Start/Stop lifecycle events to agent-events.jsonl (Copilot CLI lacks native hooks) - Shell integration: copilot() wrapper functions for bash and zsh Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AgentStatus/AgentHookInstaller.swift | 61 +++++ .../AgentStatus/AgentStatusPaths.swift | 4 + .../Features/Worktrunk/WorktrunkStore.swift | 211 ++++++++++++++++++ src/shell-integration/bash/ghostty.bash | 1 + src/shell-integration/zsh/ghostty-integration | 3 + 5 files changed, 280 insertions(+) diff --git a/macos/Sources/Features/Worktrunk/AgentStatus/AgentHookInstaller.swift b/macos/Sources/Features/Worktrunk/AgentStatus/AgentHookInstaller.swift index bb15837885..be8eadde12 100644 --- a/macos/Sources/Features/Worktrunk/AgentStatus/AgentHookInstaller.swift +++ b/macos/Sources/Features/Worktrunk/AgentStatus/AgentHookInstaller.swift @@ -51,6 +51,12 @@ enum AgentHookInstaller { marker: wrapperMarker, content: buildCodexWrapper() ) + ensureFile( + url: AgentStatusPaths.copilotCliWrapperPath, + mode: 0o755, + marker: wrapperMarker, + content: buildCopilotCliWrapper() + ) ensureFile( url: AgentStatusPaths.opencodeGlobalPluginPath, @@ -295,6 +301,61 @@ enum AgentHookInstaller { """ } + private static func buildCopilotCliWrapper() -> String { + let binDir = AgentStatusPaths.binDir.path + let eventsDir = AgentStatusPaths.eventsCacheDir.path + return """ + #!/bin/bash + \(wrapperMarker) + # Wrapper for Copilot CLI: emits Start/Stop lifecycle events. + + \(pathAugmentSnippet()) + + find_real_binary() { + local name="$1" + local IFS=: + for dir in $PATH; do + [ -z "$dir" ] && continue + dir="${dir%/}" + if [ "$dir" = "\(binDir)" ]; then + continue + fi + if [ -x "$dir/$name" ] && [ ! -d "$dir/$name" ]; then + printf "%s\\n" "$dir/$name" + return 0 + fi + done + return 1 + } + + REAL_BIN="$(find_real_binary "copilot")" + if [ -z "$REAL_BIN" ]; then + echo "Ghostree: copilot not found in PATH. Install it and ensure it is on PATH, then retry." >&2 + exit 127 + fi + + _EVENTS_DIR="${GHOSTREE_AGENT_EVENTS_DIR:-\(eventsDir)}" + + # Emit synthetic Start event + printf '{\"timestamp\":\"%s\",\"eventType\":\"Start\",\"cwd\":\"%s\"}\\n' \\ + "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" \\ + "$(pwd -P 2>/dev/null || pwd)" \\ + >> "$_EVENTS_DIR/agent-events.jsonl" 2>/dev/null + + # Run copilot and capture exit code + "$REAL_BIN" "$@" + _EXIT=$? + + # Emit synthetic Stop event + printf '{\"timestamp\":\"%s\",\"eventType\":\"Stop\",\"cwd\":\"%s\"}\\n' \\ + "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" \\ + "$(pwd -P 2>/dev/null || pwd)" \\ + >> "$_EVENTS_DIR/agent-events.jsonl" 2>/dev/null + + exit $_EXIT + """ + } + private static func buildOpenCodePlugin() -> String { let marker = AgentStatusPaths.opencodePluginMarker return """ diff --git a/macos/Sources/Features/Worktrunk/AgentStatus/AgentStatusPaths.swift b/macos/Sources/Features/Worktrunk/AgentStatus/AgentStatusPaths.swift index ccd52fad1b..f011407360 100644 --- a/macos/Sources/Features/Worktrunk/AgentStatus/AgentStatusPaths.swift +++ b/macos/Sources/Features/Worktrunk/AgentStatus/AgentStatusPaths.swift @@ -46,6 +46,10 @@ enum AgentStatusPaths { binDir.appendingPathComponent("codex") } + static var copilotCliWrapperPath: URL { + binDir.appendingPathComponent("copilot") + } + static var opencodePluginMarker: String { "// Ghostree opencode plugin v5" } /** @see https://opencode.ai/docs/plugins */ diff --git a/macos/Sources/Features/Worktrunk/WorktrunkStore.swift b/macos/Sources/Features/Worktrunk/WorktrunkStore.swift index 6aaa8e5705..4563e31599 100644 --- a/macos/Sources/Features/Worktrunk/WorktrunkStore.swift +++ b/macos/Sources/Features/Worktrunk/WorktrunkStore.swift @@ -4,12 +4,14 @@ enum SessionSource: String, Codable { case claude case codex case opencode + case copilotCli var icon: String { switch self { case .claude: return "terminal" case .codex: return "sparkles" case .opencode: return "terminal" + case .copilotCli: return "terminal" } } @@ -18,6 +20,7 @@ enum SessionSource: String, Codable { case .claude: return "Claude" case .codex: return "Codex" case .opencode: return "OpenCode" + case .copilotCli: return "Copilot" } } } @@ -126,6 +129,7 @@ struct SessionIndex: Codable { var claude: [String: SessionIndexEntry] = [:] var codex: [String: SessionIndexEntry] = [:] var opencode: [String: OpenCodeIndexEntry] = [:] + var copilotCli: [String: SessionIndexEntry] = [:] } final class SessionIndexManager { @@ -1378,6 +1382,7 @@ final class WorktrunkStore: ObservableObject { var seenClaudePaths = Set() var seenCodexPaths = Set() var seenOpenCodePaths = Set() + var seenCopilotCliPaths = Set() var openCodeSlugToWorktreePath: [String: String] = [:] for path in validWorktreePaths { @@ -1715,6 +1720,85 @@ final class WorktrunkStore: ObservableObject { index.opencode = index.opencode.filter { seenOpenCodePaths.contains($0.key) } + // Scan Copilot CLI sessions + let copilotCliSessionsDir = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".copilot/session-state") + + if FileManager.default.fileExists(atPath: copilotCliSessionsDir.path), + let sessionDirs = try? FileManager.default.contentsOfDirectory( + at: copilotCliSessionsDir, + includingPropertiesForKeys: [.isDirectoryKey] + ) { + for sessionDir in sessionDirs { + let isDirectory = (try? sessionDir.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false + guard isDirectory else { continue } + + let eventsFile = sessionDir.appendingPathComponent("events.jsonl") + guard FileManager.default.fileExists(atPath: eventsFile.path) else { continue } + + seenCopilotCliPaths.insert(eventsFile.path) + guard let attrs = fileAttributes(for: eventsFile) else { continue } + let cached = index.copilotCli[eventsFile.path] + + if let cached, + cached.fileMtime == attrs.mtime, + cached.fileSize == attrs.size { + if let worktreePath = resolveWorktreePath( + cachedPath: cached.worktreePath, + cwd: cached.cwd, + validWorktreePaths: validWorktreePaths + ) { + let session = AISession( + id: cached.sessionId, + source: .copilotCli, + worktreePath: worktreePath, + cwd: cached.cwd, + timestamp: cached.timestamp, + snippet: cached.snippet, + sourcePath: eventsFile.path, + messageCount: cached.messageCount + ) + noteSession(session, worktreePath: worktreePath) + if cached.worktreePath != worktreePath { + var updated = cached + updated.worktreePath = worktreePath + index.copilotCli[eventsFile.path] = updated + } + } + continue + } + + if var session = parseCopilotCliSession(sessionDir) { + if let worktreePath = resolveWorktreePath( + cachedPath: nil, + cwd: session.cwd, + validWorktreePaths: validWorktreePaths + ) { + session.worktreePath = worktreePath + noteSession(session, worktreePath: worktreePath) + index.copilotCli[eventsFile.path] = SessionIndexEntry( + sessionId: session.id, + cwd: session.cwd, + timestamp: session.timestamp, + snippet: session.snippet, + messageCount: session.messageCount, + worktreePath: worktreePath, + fileMtime: attrs.mtime, + fileSize: attrs.size + ) + } else { + index.copilotCli[eventsFile.path] = nil + } + } else { + index.copilotCli[eventsFile.path] = nil + } + } + } else { + index.copilotCli = [:] + } + + index.copilotCli = index.copilotCli.filter { seenCopilotCliPaths.contains($0.key) } + // Final sort and single publish sortDirtySessions() @@ -1969,6 +2053,133 @@ final class WorktrunkStore: ObservableObject { ) } + // MARK: - Copilot CLI Sessions + + private func parseCopilotCliWorkspaceYaml(_ url: URL) -> (id: String, cwd: String, summary: String?)? { + guard let content = try? String(contentsOf: url, encoding: .utf8) else { return nil } + var id: String? + var cwd: String? + var summary: String? + + for line in content.components(separatedBy: "\n") { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if let colonIdx = trimmed.firstIndex(of: ":") { + let key = String(trimmed[trimmed.startIndex.. AISession? { + let workspaceFile = sessionDir.appendingPathComponent("workspace.yaml") + let eventsFile = sessionDir.appendingPathComponent("events.jsonl") + + guard let workspace = parseCopilotCliWorkspaceYaml(workspaceFile) else { return nil } + + let eventsURL = eventsFile + guard let attrs = try? FileManager.default.attributesOfItem(atPath: eventsURL.path), + let mtime = (attrs[.modificationDate] as? Date)?.timeIntervalSince1970 else { return nil } + let size = Int64((attrs[.size] as? UInt64) ?? (attrs[.size] as? Int).map { UInt64($0) } ?? 0) + + // Check cache + if let cached = sessionCache.get(eventsURL.path), + sessionCache.isCacheValid(entry: cached, mtime: mtime, size: size) { + return AISession( + id: cached.sessionId, + source: .copilotCli, + worktreePath: "", + cwd: cached.cwd, + timestamp: cached.timestamp, + snippet: cached.snippet, + sourcePath: eventsURL.path, + messageCount: cached.messageCount + ) + } + + // Parse events.jsonl + guard let handle = try? FileHandle(forReadingFrom: eventsURL) else { return nil } + defer { try? handle.close() } + + var timestamp: Date? + var snippet: String? + var messageCount = 0 + + let data = handle.readData(ofLength: 50_000) + let content = String(data: data, encoding: .utf8) ?? "" + + for line in content.components(separatedBy: "\n").prefix(200) { + guard !line.isEmpty, + let lineData = line.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any] else { continue } + + if let ts = json["timestamp"] as? String, let parsed = parseRFC3339(ts) { + timestamp = parsed + } + + let entryType = json["type"] as? String + if entryType == "user.message", + let eventData = json["data"] as? [String: Any] { + messageCount += 1 + if snippet == nil, let text = eventData["content"] as? String { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + snippet = String(trimmed.prefix(60)) + } + } + } + } + + // For large files, count remaining user messages and get last timestamp + if size > 50_000 { + messageCount += grepCountUserMessages(url: eventsURL, pattern: "\"type\":\"user.message\"", skipBytes: 50_000) + if let tailTs = lastTimestampFromTail(url: eventsURL, fileSize: size) { + timestamp = tailTs + } + } + + // Use workspace summary as snippet if events didn't yield one + if snippet == nil, let summary = workspace.summary, !summary.isEmpty { + snippet = String(summary.prefix(60)) + } + + let ts = timestamp ?? Date.distantPast + + // Update cache + let cacheEntry = SessionCacheEntry( + sessionId: workspace.id, + source: "copilotCli", + cwd: workspace.cwd, + timestamp: ts, + snippet: snippet, + messageCount: messageCount, + lastParsedOffset: Int64(size), + fileMtime: mtime, + fileSize: size + ) + sessionCache.set(eventsURL.path, cacheEntry) + + return AISession( + id: workspace.id, + source: .copilotCli, + worktreePath: "", + cwd: workspace.cwd, + timestamp: ts, + snippet: snippet, + sourcePath: eventsURL.path, + messageCount: messageCount + ) + } + // MARK: - Session Helpers private func findMatchingWorktree(_ cwd: String) -> String? { diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index 66d246d3c2..50726193d1 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -209,6 +209,7 @@ function __ghostty_precmd() { claude() { command "$GHOSTREE_AGENT_BIN_DIR/claude" "$@"; } codex() { command "$GHOSTREE_AGENT_BIN_DIR/codex" "$@"; } + copilot() { command "$GHOSTREE_AGENT_BIN_DIR/copilot" "$@"; } fi if test "$_ghostty_executing" != "0"; then diff --git a/src/shell-integration/zsh/ghostty-integration b/src/shell-integration/zsh/ghostty-integration index 8469449c04..532987b7cd 100644 --- a/src/shell-integration/zsh/ghostty-integration +++ b/src/shell-integration/zsh/ghostty-integration @@ -110,6 +110,9 @@ _ghostty_deferred_init() { if (( $+functions[codex] == 0 )); then codex() { command "$GHOSTREE_AGENT_BIN_DIR/codex" "$@"; } fi + if (( $+functions[copilot] == 0 )); then + copilot() { command "$GHOSTREE_AGENT_BIN_DIR/copilot" "$@"; } + fi fi # Don't write OSC 133 D when our precmd handler is invoked from zle. From bf1fc5a6a6e9547ff0532182de4096acb30bf79c Mon Sep 17 00:00:00 2001 From: Greggory Rothmeier Date: Mon, 2 Mar 2026 14:19:23 -0500 Subject: [PATCH 2/3] Add built-in support for GitHub Copilot CLI agent Add Copilot CLI as a supported agent alongside Claude, Codex, and OpenCode. This includes: - SessionSource: new .copilotCli case with icon and label - SessionIndex: new copilotCli dictionary for index caching - Session scanning: discovers sessions from ~/.copilot/session-state/ - Session parsing: reads workspace.yaml for metadata and events.jsonl for timestamps, user message counts, and snippets - Agent wrapper: shell script that emits Start/Stop lifecycle events to agent-events.jsonl (Copilot CLI lacks native hooks) - Shell integration: copilot() wrapper functions for bash and zsh - WorktrunkAgent/WorktrunkDefaultAction: new .copilotCli cases - TerminalController: resume support via copilot --resume Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../xcshareddata/xcschemes/Ghostree.xcscheme | 12 ++++++------ .../xcshareddata/xcschemes/Ghostty.xcscheme | 12 ++++++------ .../Features/Terminal/TerminalController.swift | 2 ++ .../Features/Worktrunk/WorktrunkPreferences.swift | 6 ++++++ 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/macos/Ghostty.xcodeproj/xcshareddata/xcschemes/Ghostree.xcscheme b/macos/Ghostty.xcodeproj/xcshareddata/xcschemes/Ghostree.xcscheme index 2b4f815eaa..02e5d39a0c 100644 --- a/macos/Ghostty.xcodeproj/xcshareddata/xcschemes/Ghostree.xcscheme +++ b/macos/Ghostty.xcodeproj/xcshareddata/xcschemes/Ghostree.xcscheme @@ -15,8 +15,8 @@ @@ -68,8 +68,8 @@ @@ -98,8 +98,8 @@ diff --git a/macos/Ghostty.xcodeproj/xcshareddata/xcschemes/Ghostty.xcscheme b/macos/Ghostty.xcodeproj/xcshareddata/xcschemes/Ghostty.xcscheme index 2b4f815eaa..02e5d39a0c 100644 --- a/macos/Ghostty.xcodeproj/xcshareddata/xcschemes/Ghostty.xcscheme +++ b/macos/Ghostty.xcodeproj/xcshareddata/xcschemes/Ghostty.xcscheme @@ -15,8 +15,8 @@ @@ -68,8 +68,8 @@ @@ -98,8 +98,8 @@ diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index c9ffa9f2bf..133a837a61 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -1961,6 +1961,8 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr base.command = "codex resume \(session.id)" case .opencode: base.command = "opencode --session \(session.id)" + case .copilotCli: + base.command = "copilot --resume \(session.id)" } if WorktrunkPreferences.worktreeTabsEnabled { diff --git a/macos/Sources/Features/Worktrunk/WorktrunkPreferences.swift b/macos/Sources/Features/Worktrunk/WorktrunkPreferences.swift index 9559d5f67d..5e65534f62 100644 --- a/macos/Sources/Features/Worktrunk/WorktrunkPreferences.swift +++ b/macos/Sources/Features/Worktrunk/WorktrunkPreferences.swift @@ -5,6 +5,7 @@ enum WorktrunkAgent: String, CaseIterable, Identifiable { case claude case codex case opencode + case copilotCli var id: String { rawValue } @@ -13,6 +14,7 @@ enum WorktrunkAgent: String, CaseIterable, Identifiable { case .claude: return "Claude Code" case .codex: return "Codex" case .opencode: return "OpenCode" + case .copilotCli: return "Copilot" } } @@ -21,6 +23,7 @@ enum WorktrunkAgent: String, CaseIterable, Identifiable { case .claude: return "claude" case .codex: return "codex" case .opencode: return "opencode" + case .copilotCli: return "copilot" } } @@ -76,6 +79,7 @@ enum WorktrunkDefaultAction: String, CaseIterable, Identifiable { case claude case codex case opencode + case copilotCli var id: String { rawValue } @@ -85,6 +89,7 @@ enum WorktrunkDefaultAction: String, CaseIterable, Identifiable { case .claude: return "Claude Code" case .codex: return "Codex" case .opencode: return "OpenCode" + case .copilotCli: return "Copilot" } } @@ -94,6 +99,7 @@ enum WorktrunkDefaultAction: String, CaseIterable, Identifiable { case .claude: return .claude case .codex: return .codex case .opencode: return .opencode + case .copilotCli: return .copilotCli } } From 2990f8843e3c23f421850b7d0717c57d2be659b5 Mon Sep 17 00:00:00 2001 From: Greggory Rothmeier Date: Mon, 2 Mar 2026 15:00:59 -0500 Subject: [PATCH 3/3] Focus changes on Copilot CLI support --- .../xcshareddata/xcschemes/Ghostree.xcscheme | 12 ++++++------ .../xcshareddata/xcschemes/Ghostty.xcscheme | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/macos/Ghostty.xcodeproj/xcshareddata/xcschemes/Ghostree.xcscheme b/macos/Ghostty.xcodeproj/xcshareddata/xcschemes/Ghostree.xcscheme index 02e5d39a0c..2b4f815eaa 100644 --- a/macos/Ghostty.xcodeproj/xcshareddata/xcschemes/Ghostree.xcscheme +++ b/macos/Ghostty.xcodeproj/xcshareddata/xcschemes/Ghostree.xcscheme @@ -15,8 +15,8 @@ @@ -68,8 +68,8 @@ @@ -98,8 +98,8 @@ diff --git a/macos/Ghostty.xcodeproj/xcshareddata/xcschemes/Ghostty.xcscheme b/macos/Ghostty.xcodeproj/xcshareddata/xcschemes/Ghostty.xcscheme index 02e5d39a0c..2b4f815eaa 100644 --- a/macos/Ghostty.xcodeproj/xcshareddata/xcschemes/Ghostty.xcscheme +++ b/macos/Ghostty.xcodeproj/xcshareddata/xcschemes/Ghostty.xcscheme @@ -15,8 +15,8 @@ @@ -68,8 +68,8 @@ @@ -98,8 +98,8 @@