From 9bfa84232b3afd2effb1341a25c4273e8c7e05f6 Mon Sep 17 00:00:00 2001 From: Morten Trydal Date: Fri, 31 Jul 2026 13:26:16 +0200 Subject: [PATCH 1/3] Add Copilot CLI session support --- README.md | 9 +- .../Agents/AgentRuntimeBridge.swift | 224 +++++++++++++++++- .../Ghostty/GhosttyRuntime.swift | 2 + Sources/Shellraiser/Models/AgentType.swift | 5 + .../WorkspaceManager+Completions.swift | 2 + ...WorkspaceSurfaceManager+SurfaceState.swift | 9 + .../AgentCompletionEventTests.swift | 14 ++ .../AgentRuntimeBridgeTests.swift | 38 ++- .../AgentTypeAndWorkspaceModelTests.swift | 3 + .../GhosttyRuntimeCommandTests.swift | 29 +++ ...spaceSurfaceManagerSurfaceStateTests.swift | 38 +++ 11 files changed, 361 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 79837da..e3b44a0 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,8 @@ Shellraiser is a macOS terminal workspace app built with SwiftUI and GhosttyKit. - AppleScript support for creating workspaces, splitting terminals, focusing surfaces, sending keys, and inputting text - macOS notifications — native notification when an agent turn completes in an unfocused surface; click to jump to it - Git branch display — sidebar shows current branch name and a linked-worktree indicator per workspace -- Session resume — agent sessions (Claude Code, Codex) persist across app restarts +- Managed agent sessions — Claude Code, Codex, and Copilot CLI report activity and completion state to Shellraiser +- Session resume — Claude Code, Codex, and Copilot CLI sessions persist across app restarts - Workspace persistence — full layout, pane tree, and surface state saved/restored automatically - Pane zoom — toggle any split pane to fill the workspace area (Cmd+Shift+Return) - Ghostty theming — terminal appearance driven by Ghostty config (background, foreground, opacity, blur, split divider color, unfocused split dimming) @@ -49,6 +50,12 @@ Run the app locally: make run ``` +## Copilot CLI + +Shellraiser supports interactive [GitHub Copilot CLI](https://docs.github.com/en/copilot/how-tos/copilot-cli) sessions and resumes them with their saved session ID after an app restart. Install and authenticate the `copilot` command before starting a session. + +While a Copilot session is active, Shellraiser creates its own temporary user hook file to receive lifecycle events for activity and completion tracking. It leaves existing Copilot user, repository, policy, and plugin hooks unchanged. If Shellraiser stops unexpectedly, the hook remains while the Copilot process is alive; stale Shellraiser hook state is removed the next time Shellraiser or a managed Copilot session starts. + ## Automation Shellraiser exposes AppleScript objects and commands for workspaces, terminals, and surface configurations. diff --git a/Sources/Shellraiser/Infrastructure/Agents/AgentRuntimeBridge.swift b/Sources/Shellraiser/Infrastructure/Agents/AgentRuntimeBridge.swift index 0a7e179..c6916de 100644 --- a/Sources/Shellraiser/Infrastructure/Agents/AgentRuntimeBridge.swift +++ b/Sources/Shellraiser/Infrastructure/Agents/AgentRuntimeBridge.swift @@ -9,6 +9,7 @@ final class AgentRuntimeBridge: AgentRuntimeSupporting { let binDirectory: URL let zshShimDirectory: URL let eventLogURL: URL + let copilotHomeURL: URL private let fileManager: FileManager @@ -18,17 +19,25 @@ final class AgentRuntimeBridge: AgentRuntimeSupporting { rootURL: FileManager.default.temporaryDirectory.appendingPathComponent( "ShellraiserRuntime", isDirectory: true - ) + ), + copilotHomeURL: FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".copilot", isDirectory: true) ) } /// Creates a bridge rooted in the supplied directory for isolated runtime support. - init(rootURL: URL, fileManager: FileManager = .default) { + init( + rootURL: URL, + copilotHomeURL: URL? = nil, + fileManager: FileManager = .default + ) { self.fileManager = fileManager self.runtimeDirectory = rootURL self.binDirectory = rootURL.appendingPathComponent("bin", isDirectory: true) self.zshShimDirectory = rootURL.appendingPathComponent("zsh", isDirectory: true) self.eventLogURL = rootURL.appendingPathComponent("agent-completions.log") + self.copilotHomeURL = copilotHomeURL + ?? rootURL.appendingPathComponent("copilot-home", isDirectory: true) prepareRuntimeSupport() } @@ -58,6 +67,15 @@ final class AgentRuntimeBridge: AgentRuntimeSupporting { named: "codex", contents: codexWrapperContents ) + try writeExecutable( + named: "copilot", + contents: copilotWrapperContents + ) + try writeExecutable( + named: "shellraiser-copilot-hooks", + contents: copilotHookManagerContents + ) + try reapCopilotHookLeases() try writeTextFile( at: zshShimDirectory.appendingPathComponent(".zshenv"), contents: zshEnvContents @@ -97,6 +115,7 @@ final class AgentRuntimeBridge: AgentRuntimeSupporting { environment["SHELLRAISER_EVENT_LOG"] = eventLogURL.path environment["SHELLRAISER_SURFACE_ID"] = surfaceId.uuidString environment["SHELLRAISER_HELPER_PATH"] = binDirectory.appendingPathComponent("shellraiser-agent-complete").path + environment["SHELLRAISER_COPILOT_HOME"] = copilotHomeURL.path if shellPath.hasSuffix("/zsh") || shellPath == "zsh" { environment["ZDOTDIR"] = zshShimDirectory.path @@ -128,6 +147,25 @@ final class AgentRuntimeBridge: AgentRuntimeSupporting { try data.write(to: fileURL, options: .atomic) } + /// Reaps stale Copilot hook leases at startup without touching user-owned hook files. + private func reapCopilotHookLeases() throws { + let managerURL = binDirectory.appendingPathComponent("shellraiser-copilot-hooks") + let process = Process() + process.executableURL = managerURL + process.arguments = ["reap"] + process.environment = ProcessInfo.processInfo.environment.merging( + ["SHELLRAISER_COPILOT_HOME": copilotHomeURL.path] + ) { _, bridgeValue in bridgeValue } + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try process.run() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + throw CocoaError(.fileWriteUnknown) + } + } + /// Returns the advisory lock file shared by helper writers and the monitor compactor. private var eventLogLockURL: URL { eventLogURL.appendingPathExtension("lock") @@ -158,7 +196,7 @@ final class AgentRuntimeBridge: AgentRuntimeSupporting { esac case "$runtime:$phase" in - codex:session|claudeCode:session) + codex:session|claudeCode:session|copilot:session) payload="${4:-}" session_id="$payload" ;; @@ -170,6 +208,13 @@ final class AgentRuntimeBridge: AgentRuntimeSupporting { payload="$(printf '%s\n%s' "$session_id" "$transcript_path")" phase="session" ;; + copilot:hook-session) + hook_payload="$(cat)" + compact_payload="$(printf '%s' "$hook_payload" | tr -d '\n')" + session_id="$(printf '%s' "$compact_payload" | sed -n 's/.*"sessionId"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | sed -n '1p')" + payload="$session_id" + phase="session" + ;; esac if [ "$phase" = "session" ] && [ -z "$session_id" ]; then @@ -360,6 +405,171 @@ final class AgentRuntimeBridge: AgentRuntimeSupporting { """# } + /// Copilot CLI wrapper that installs Shellraiser-owned lifecycle hooks while it supervises a session. + private var copilotWrapperContents: String { + #""" + #!/bin/sh + set -eu + + real="${SHELLRAISER_REAL_COPILOT:-}" + lookup_path="${SHELLRAISER_ORIGINAL_PATH:-${PATH:-}}" + if [ -z "$real" ] || [ "$real" = "$0" ]; then + real="$(PATH="$lookup_path" /usr/bin/which copilot 2>/dev/null || true)" + fi + + if [ -z "$real" ] || [ "$real" = "$0" ]; then + echo "Shellraiser could not resolve the real Copilot CLI binary." >&2 + exit 127 + fi + + helper="${SHELLRAISER_HELPER_PATH:-$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/shellraiser-agent-complete}" + manager="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/shellraiser-copilot-hooks" + surface="${SHELLRAISER_SURFACE_ID:-}" + + if [ -z "$surface" ] || [ ! -x "$helper" ] || [ ! -x "$manager" ]; then + exec "$real" "$@" + fi + + export SHELLRAISER_HELPER_PATH="$helper" + export SHELLRAISER_SURFACE_ID="$surface" + + if ! lease="$("$manager" acquire "$$")"; then + echo "Shellraiser could not install its Copilot lifecycle hooks; starting Copilot without managed tracking." >&2 + exec "$real" "$@" + fi + + cleanup() { + "$manager" release "$lease" >/dev/null 2>&1 || true + } + child="" + terminate() { + signal="$1" + status="$2" + if [ -n "$child" ]; then + /bin/kill "-$signal" "$child" 2>/dev/null || true + wait "$child" || true + fi + cleanup + exit "$status" + } + trap 'terminate HUP 129' HUP + trap 'terminate INT 130' INT + trap 'terminate TERM 143' TERM + + set +e + "$real" "$@" < /dev/tty & + child=$! + "$manager" replace "$lease" "$child" >/dev/null 2>&1 || true + wait "$child" + status=$? + set -e + "$helper" copilot "$surface" exited || true + cleanup + exit "$status" + """# + } + + /// Shared, lock-protected manager for the temporary Copilot user hook file and process leases. + private var copilotHookManagerContents: String { + #""" + #!/bin/sh + set -eu + + home="${SHELLRAISER_COPILOT_HOME:-${COPILOT_HOME:-$HOME/.copilot}}" + hook_dir="$home/hooks" + state_dir="$home/shellraiser" + hook_file="$hook_dir/shellraiser-managed.json" + leases_file="$state_dir/copilot-leases" + lock_file="$state_dir/copilot-hooks.lock" + action="${1:-}" + + fingerprint() { + /bin/ps -o lstart= -p "$1" 2>/dev/null | /usr/bin/tr -d ' ' || true + } + + is_live() { + [ -n "$2" ] && [ "$(fingerprint "$1")" = "$2" ] + } + + write_hook_file() { + /bin/mkdir -p "$hook_dir" + /bin/cat > "$hook_file" <<'EOF' + { + "version": 1, + "hooks": { + "sessionStart": [{"type": "command", "bash": "if [ -n \"${SHELLRAISER_HELPER_PATH:-}\" ] && [ -n \"${SHELLRAISER_SURFACE_ID:-}\" ]; then \"$SHELLRAISER_HELPER_PATH\" copilot \"$SHELLRAISER_SURFACE_ID\" hook-session; fi", "timeoutSec": 5}], + "userPromptSubmitted": [{"type": "command", "bash": "if [ -n \"${SHELLRAISER_HELPER_PATH:-}\" ] && [ -n \"${SHELLRAISER_SURFACE_ID:-}\" ]; then \"$SHELLRAISER_HELPER_PATH\" copilot \"$SHELLRAISER_SURFACE_ID\" started; fi", "timeoutSec": 5}], + "preToolUse": [{"type": "command", "bash": "if [ -n \"${SHELLRAISER_HELPER_PATH:-}\" ] && [ -n \"${SHELLRAISER_SURFACE_ID:-}\" ]; then \"$SHELLRAISER_HELPER_PATH\" copilot \"$SHELLRAISER_SURFACE_ID\" started; fi", "timeoutSec": 5}], + "agentStop": [{"type": "command", "bash": "if [ -n \"${SHELLRAISER_HELPER_PATH:-}\" ] && [ -n \"${SHELLRAISER_SURFACE_ID:-}\" ]; then \"$SHELLRAISER_HELPER_PATH\" copilot \"$SHELLRAISER_SURFACE_ID\" completed; fi", "timeoutSec": 5}], + "notification": [{"type": "command", "matcher": "permission_prompt|elicitation_dialog", "bash": "if [ -n \"${SHELLRAISER_HELPER_PATH:-}\" ] && [ -n \"${SHELLRAISER_SURFACE_ID:-}\" ]; then \"$SHELLRAISER_HELPER_PATH\" copilot \"$SHELLRAISER_SURFACE_ID\" completed; fi", "timeoutSec": 5}], + "sessionEnd": [{"type": "command", "bash": "if [ -n \"${SHELLRAISER_HELPER_PATH:-}\" ] && [ -n \"${SHELLRAISER_SURFACE_ID:-}\" ]; then \"$SHELLRAISER_HELPER_PATH\" copilot \"$SHELLRAISER_SURFACE_ID\" exited; fi", "timeoutSec": 5}] + } + } + EOF + } + + reap() { + : > "$leases_file.next" + if [ -f "$leases_file" ]; then + while IFS='|' read -r token pid started; do + if is_live "$pid" "$started"; then + printf '%s|%s|%s\n' "$token" "$pid" "$started" >> "$leases_file.next" + fi + done < "$leases_file" + fi + /bin/mv "$leases_file.next" "$leases_file" + if [ ! -s "$leases_file" ]; then + /bin/rm -f "$hook_file" + fi + } + + locked() { + /bin/mkdir -p "$state_dir" + reap + case "$action" in + acquire) + pid="$1" + started="$(fingerprint "$pid")" + [ -n "$started" ] || exit 1 + token="$pid-$(date +%s)-${RANDOM:-0}" + printf '%s|%s|%s\n' "$token" "$pid" "$started" >> "$leases_file" + write_hook_file + printf '%s\n' "$token" + ;; + replace) + token="$1" + pid="$2" + started="$(fingerprint "$pid")" + [ -n "$started" ] || exit 1 + /usr/bin/awk -F'|' -v token="$token" -v pid="$pid" -v started="$started" 'BEGIN { OFS="|" } $1 == token { print token, pid, started; next } { print }' "$leases_file" > "$leases_file.next" + /bin/mv "$leases_file.next" "$leases_file" + ;; + release) + token="$1" + /usr/bin/awk -F'|' -v token="$token" '$1 != token' "$leases_file" > "$leases_file.next" + /bin/mv "$leases_file.next" "$leases_file" + reap + ;; + reap) + ;; + *) + exit 64 + ;; + esac + } + + if [ "$action" = "locked" ]; then + shift + action="${1:-}" + shift + locked "$@" + else + /bin/mkdir -p "$state_dir" + /usr/bin/lockf "$lock_file" /bin/sh -c '"$@"' sh "$0" locked "$@" + fi + """# + } + /// zsh shim that sources the user's original `.zshenv` and reapplies Shellraiser runtime vars. private var zshEnvContents: String { #""" @@ -368,7 +578,7 @@ final class AgentRuntimeBridge: AgentRuntimeSupporting { fi export PATH="${SHELLRAISER_WRAPPER_BIN}:${PATH:-${SHELLRAISER_ORIGINAL_PATH}}" - export SHELLRAISER_EVENT_LOG SHELLRAISER_SURFACE_ID SHELLRAISER_HELPER_PATH SHELLRAISER_REAL_CLAUDE SHELLRAISER_REAL_CODEX SHELLRAISER_WRAPPER_BIN SHELLRAISER_ORIGINAL_PATH + export SHELLRAISER_EVENT_LOG SHELLRAISER_SURFACE_ID SHELLRAISER_HELPER_PATH SHELLRAISER_REAL_CLAUDE SHELLRAISER_REAL_CODEX SHELLRAISER_REAL_COPILOT SHELLRAISER_WRAPPER_BIN SHELLRAISER_ORIGINAL_PATH """# } @@ -380,7 +590,7 @@ final class AgentRuntimeBridge: AgentRuntimeSupporting { fi export PATH="${SHELLRAISER_WRAPPER_BIN}:${PATH:-${SHELLRAISER_ORIGINAL_PATH}}" - export SHELLRAISER_EVENT_LOG SHELLRAISER_SURFACE_ID SHELLRAISER_HELPER_PATH SHELLRAISER_REAL_CLAUDE SHELLRAISER_REAL_CODEX SHELLRAISER_WRAPPER_BIN SHELLRAISER_ORIGINAL_PATH + export SHELLRAISER_EVENT_LOG SHELLRAISER_SURFACE_ID SHELLRAISER_HELPER_PATH SHELLRAISER_REAL_CLAUDE SHELLRAISER_REAL_CODEX SHELLRAISER_REAL_COPILOT SHELLRAISER_WRAPPER_BIN SHELLRAISER_ORIGINAL_PATH """# } @@ -396,7 +606,7 @@ final class AgentRuntimeBridge: AgentRuntimeSupporting { fi export PATH="${SHELLRAISER_WRAPPER_BIN}:${PATH:-${SHELLRAISER_ORIGINAL_PATH}}" - export SHELLRAISER_EVENT_LOG SHELLRAISER_SURFACE_ID SHELLRAISER_HELPER_PATH SHELLRAISER_REAL_CLAUDE SHELLRAISER_REAL_CODEX SHELLRAISER_WRAPPER_BIN SHELLRAISER_ORIGINAL_PATH + export SHELLRAISER_EVENT_LOG SHELLRAISER_SURFACE_ID SHELLRAISER_HELPER_PATH SHELLRAISER_REAL_CLAUDE SHELLRAISER_REAL_CODEX SHELLRAISER_REAL_COPILOT SHELLRAISER_WRAPPER_BIN SHELLRAISER_ORIGINAL_PATH """# } @@ -408,7 +618,7 @@ final class AgentRuntimeBridge: AgentRuntimeSupporting { fi export PATH="${SHELLRAISER_WRAPPER_BIN}:${PATH:-${SHELLRAISER_ORIGINAL_PATH}}" - export SHELLRAISER_EVENT_LOG SHELLRAISER_SURFACE_ID SHELLRAISER_HELPER_PATH SHELLRAISER_REAL_CLAUDE SHELLRAISER_REAL_CODEX SHELLRAISER_WRAPPER_BIN SHELLRAISER_ORIGINAL_PATH + export SHELLRAISER_EVENT_LOG SHELLRAISER_SURFACE_ID SHELLRAISER_HELPER_PATH SHELLRAISER_REAL_CLAUDE SHELLRAISER_REAL_CODEX SHELLRAISER_REAL_COPILOT SHELLRAISER_WRAPPER_BIN SHELLRAISER_ORIGINAL_PATH """# } } diff --git a/Sources/Shellraiser/Infrastructure/Ghostty/GhosttyRuntime.swift b/Sources/Shellraiser/Infrastructure/Ghostty/GhosttyRuntime.swift index d8e3954..2f3dc3c 100644 --- a/Sources/Shellraiser/Infrastructure/Ghostty/GhosttyRuntime.swift +++ b/Sources/Shellraiser/Infrastructure/Ghostty/GhosttyRuntime.swift @@ -1243,6 +1243,8 @@ final class GhosttyRuntime { return surfaceModel.agentType.resumeCommand(sessionId: surfaceModel.sessionId) case .codex: return surfaceModel.agentType.resumeCommand(sessionId: surfaceModel.sessionId) + case .copilot: + return surfaceModel.agentType.resumeCommand(sessionId: surfaceModel.sessionId) } } diff --git a/Sources/Shellraiser/Models/AgentType.swift b/Sources/Shellraiser/Models/AgentType.swift index 47e954c..9fbc14f 100644 --- a/Sources/Shellraiser/Models/AgentType.swift +++ b/Sources/Shellraiser/Models/AgentType.swift @@ -4,6 +4,7 @@ import Foundation enum AgentType: String, Codable, CaseIterable { case claudeCode case codex + case copilot /// Human-readable display name for UI labels. var displayName: String { @@ -12,6 +13,8 @@ enum AgentType: String, Codable, CaseIterable { return "Claude Code" case .codex: return "Codex" + case .copilot: + return "Copilot CLI" } } @@ -26,6 +29,8 @@ enum AgentType: String, Codable, CaseIterable { return ("claude", ["--resume", sessionId]) case .codex: return ("codex", ["resume", sessionId]) + case .copilot: + return ("copilot", ["--resume", sessionId]) } } } diff --git a/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+Completions.swift b/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+Completions.swift index 78f33dd..e57de2c 100644 --- a/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+Completions.swift +++ b/Sources/Shellraiser/Services/Workspaces/WorkspaceManager+Completions.swift @@ -221,6 +221,8 @@ extension WorkspaceManager { return (sessionId, transcriptPath) case .codex: return (event.payload, nil) + case .copilot: + return (event.payload, nil) } } diff --git a/Sources/Shellraiser/Services/Workspaces/WorkspaceSurfaceManager+SurfaceState.swift b/Sources/Shellraiser/Services/Workspaces/WorkspaceSurfaceManager+SurfaceState.swift index 444dfe2..06637ef 100644 --- a/Sources/Shellraiser/Services/Workspaces/WorkspaceSurfaceManager+SurfaceState.swift +++ b/Sources/Shellraiser/Services/Workspaces/WorkspaceSurfaceManager+SurfaceState.swift @@ -54,6 +54,11 @@ extension WorkspaceSurfaceManager { surface.transcriptPath = "" didChange = true } + case .copilot: + if !surface.transcriptPath.isEmpty { + surface.transcriptPath = "" + didChange = true + } } if !surface.shouldResumeSession { @@ -78,6 +83,8 @@ extension WorkspaceSurfaceManager { return trimmedSessionId.lowercased() case .codex: return trimmedSessionId + case .copilot: + return trimmedSessionId } } @@ -91,6 +98,8 @@ extension WorkspaceSurfaceManager { return trimmedTranscriptPath case .codex: return "" + case .copilot: + return "" } } diff --git a/Tests/ShellraiserTests/AgentCompletionEventTests.swift b/Tests/ShellraiserTests/AgentCompletionEventTests.swift index 393e78d..b7a4ad8 100644 --- a/Tests/ShellraiserTests/AgentCompletionEventTests.swift +++ b/Tests/ShellraiserTests/AgentCompletionEventTests.swift @@ -32,6 +32,20 @@ final class AgentCompletionEventTests: XCTestCase { XCTAssertEqual(event?.payload, "019ce8bb-b369-7693-9be0-664a228e4e24") } + /// Verifies Copilot session-identity events decode through the shared event log format. + func testParseDecodesCopilotSessionIdentityEvent() { + let surfaceId = UUID(uuidString: "00000000-0000-0000-0000-000000001404")! + let payload = Data("4fbe8379-2298-43e8-a8e6-ae0fe9a46217".utf8).base64EncodedString() + let line = "2026-03-08T20:07:00Z\tcopilot\t\(surfaceId.uuidString)\tsession\t\(payload)" + + let event = AgentActivityEvent.parse(line) + + XCTAssertEqual(event?.agentType, .copilot) + XCTAssertEqual(event?.surfaceId, surfaceId) + XCTAssertEqual(event?.phase, .session) + XCTAssertEqual(event?.payload, "4fbe8379-2298-43e8-a8e6-ae0fe9a46217") + } + /// Verifies exit lifecycle events decode without requiring a payload body. func testParseDecodesExitedEvent() { let surfaceId = UUID(uuidString: "00000000-0000-0000-0000-000000001403")! diff --git a/Tests/ShellraiserTests/AgentRuntimeBridgeTests.swift b/Tests/ShellraiserTests/AgentRuntimeBridgeTests.swift index 4701ebd..c969b51 100644 --- a/Tests/ShellraiserTests/AgentRuntimeBridgeTests.swift +++ b/Tests/ShellraiserTests/AgentRuntimeBridgeTests.swift @@ -42,11 +42,13 @@ final class AgentRuntimeBridgeTests: XCTestCase { environment["SHELLRAISER_HELPER_PATH"], bridge.binDirectory.appendingPathComponent("shellraiser-agent-complete").path ) + XCTAssertEqual(environment["SHELLRAISER_COPILOT_HOME"], bridge.copilotHomeURL.path) XCTAssertEqual(environment["ZDOTDIR"], bridge.zshShimDirectory.path) XCTAssertEqual(environment["SHELLRAISER_WRAPPER_BIN"], bridge.binDirectory.path) XCTAssertEqual(environment["SHELLRAISER_ORIGINAL_PATH"], "/usr/local/bin:/usr/bin:/bin") XCTAssertNil(environment["SHELLRAISER_REAL_CLAUDE"]) XCTAssertNil(environment["SHELLRAISER_REAL_CODEX"]) + XCTAssertNil(environment["SHELLRAISER_REAL_COPILOT"]) } /// Verifies the Claude wrapper emits start, stop, permission-request, and selected notification hooks. @@ -73,8 +75,8 @@ final class AgentRuntimeBridgeTests: XCTestCase { XCTAssertFalse(wrapperContents.contains("\"SubagentStop\"")) } - /// Verifies the helper script only matches fully qualified Codex runtime phases. - func testPrepareRuntimeSupportWritesHelperWithoutBareCodexCase() throws { + /// Verifies the helper script only matches fully qualified managed runtime phases. + func testPrepareRuntimeSupportWritesHelperWithoutBareRuntimeCases() throws { let bridge = try makeBridge() let helperURL = bridge.binDirectory.appendingPathComponent("shellraiser-agent-complete") let lockURL = bridge.runtimeDirectory.appendingPathComponent("agent-completions.log.lock") @@ -83,8 +85,10 @@ final class AgentRuntimeBridgeTests: XCTestCase { let helperContents = try String(contentsOf: helperURL, encoding: .utf8) - XCTAssertTrue(helperContents.contains("codex:session|claudeCode:session)")) + XCTAssertTrue(helperContents.contains("codex:session|claudeCode:session|copilot:session)")) XCTAssertTrue(helperContents.contains("claudeCode:hook-session|codex:hook-session)")) + XCTAssertTrue(helperContents.contains("copilot:hook-session)")) + XCTAssertTrue(helperContents.contains("\"sessionId\"")) XCTAssertTrue(helperContents.contains("session_id=\"\"")) XCTAssertTrue(helperContents.contains("if [ \"$phase\" = \"session\" ] && [ -z \"$session_id\" ]; then")) XCTAssertTrue(helperContents.contains("/usr/bin/lockf")) @@ -99,11 +103,15 @@ final class AgentRuntimeBridgeTests: XCTestCase { let bridge = try makeBridge() let claudeWrapperURL = bridge.binDirectory.appendingPathComponent("claude") let codexWrapperURL = bridge.binDirectory.appendingPathComponent("codex") + let copilotWrapperURL = bridge.binDirectory.appendingPathComponent("copilot") + let copilotHookManagerURL = bridge.binDirectory.appendingPathComponent("shellraiser-copilot-hooks") bridge.prepareRuntimeSupport() let claudeWrapperContents = try String(contentsOf: claudeWrapperURL, encoding: .utf8) let codexWrapperContents = try String(contentsOf: codexWrapperURL, encoding: .utf8) + let copilotWrapperContents = try String(contentsOf: copilotWrapperURL, encoding: .utf8) + let copilotHookManagerContents = try String(contentsOf: copilotHookManagerURL, encoding: .utf8) // Claude wrapper: hook-based session and lifecycle XCTAssertTrue(claudeWrapperContents.contains("hook-session")) @@ -132,6 +140,26 @@ final class AgentRuntimeBridgeTests: XCTestCase { XCTAssertFalse(codexWrapperContents.contains("normalize_codex_session_timestamp")) XCTAssertFalse(codexWrapperContents.contains("monitor_pid")) XCTAssertFalse(codexWrapperContents.contains("notify_config")) + + // Copilot: managed user hook file with a supervised, crash-recoverable lease. + XCTAssertTrue(copilotWrapperContents.contains("SHELLRAISER_REAL_COPILOT")) + XCTAssertTrue(copilotWrapperContents.contains("PATH=\"$lookup_path\" /usr/bin/which copilot")) + XCTAssertTrue(copilotWrapperContents.contains("\"$manager\" acquire \"$$\"")) + XCTAssertTrue(copilotWrapperContents.contains("\"$manager\" replace \"$lease\" \"$child\"")) + XCTAssertTrue(copilotWrapperContents.contains("\"$helper\" copilot \"$surface\" exited")) + XCTAssertTrue(copilotWrapperContents.contains("\"$real\" \"$@\" < /dev/tty &")) + XCTAssertTrue(copilotWrapperContents.contains("/bin/kill \"-$signal\" \"$child\"")) + XCTAssertTrue(copilotWrapperContents.contains("wait \"$child\" || true")) + XCTAssertTrue(copilotHookManagerContents.contains("shellraiser-managed.json")) + XCTAssertTrue(copilotHookManagerContents.contains("\"sessionStart\"")) + XCTAssertTrue(copilotHookManagerContents.contains("\"userPromptSubmitted\"")) + XCTAssertTrue(copilotHookManagerContents.contains("\"preToolUse\"")) + XCTAssertTrue(copilotHookManagerContents.contains("\"agentStop\"")) + XCTAssertTrue(copilotHookManagerContents.contains("\"sessionEnd\"")) + XCTAssertTrue(copilotHookManagerContents.contains("permission_prompt|elicitation_dialog")) + XCTAssertTrue(copilotHookManagerContents.contains("copilot-leases")) + XCTAssertTrue(copilotHookManagerContents.contains("fingerprint")) + XCTAssertTrue(copilotHookManagerContents.contains("/usr/bin/lockf")) } /// Verifies the zsh shim sources Ghostty shell integration when the runtime is active. @@ -152,7 +180,7 @@ final class AgentRuntimeBridgeTests: XCTestCase { XCTAssertTrue(zshRcContents.contains("shell-integration/zsh/ghostty-integration")) } - /// Verifies the helper can extract session identifiers from hook payloads for both runtimes. + /// Verifies the helper can extract session identifiers from hook payloads for all runtimes. func testPrepareRuntimeSupportWritesHelperWithHookSessionParsing() throws { let bridge = try makeBridge() let helperURL = bridge.binDirectory.appendingPathComponent("shellraiser-agent-complete") @@ -165,6 +193,8 @@ final class AgentRuntimeBridgeTests: XCTestCase { XCTAssertTrue(helperContents.contains("claudeCode:hook-session|codex:hook-session)")) XCTAssertTrue(helperContents.contains("\"session_id\"")) XCTAssertTrue(helperContents.contains("\"transcript_path\"")) + XCTAssertTrue(helperContents.contains("copilot:hook-session")) + XCTAssertTrue(helperContents.contains("\"sessionId\"")) XCTAssertFalse(helperContents.contains("/usr/bin/python3")) XCTAssertTrue(helperContents.contains("phase=\"session\"")) } diff --git a/Tests/ShellraiserTests/AgentTypeAndWorkspaceModelTests.swift b/Tests/ShellraiserTests/AgentTypeAndWorkspaceModelTests.swift index aa5b2e5..fa5cfef 100644 --- a/Tests/ShellraiserTests/AgentTypeAndWorkspaceModelTests.swift +++ b/Tests/ShellraiserTests/AgentTypeAndWorkspaceModelTests.swift @@ -9,11 +9,14 @@ final class AgentTypeAndWorkspaceModelTests: XCTestCase { let claudeResumeCommand = AgentType.claudeCode.resumeCommand(sessionId: "abc123") let codexResumeCommand = AgentType.codex.resumeCommand(sessionId: "xyz789") + let copilotResumeCommand = AgentType.copilot.resumeCommand(sessionId: "copilot-session") XCTAssertEqual(claudeResumeCommand?.command, "claude") XCTAssertEqual(claudeResumeCommand?.arguments, ["--resume", "abc123"]) XCTAssertEqual(codexResumeCommand?.command, "codex") XCTAssertEqual(codexResumeCommand?.arguments, ["resume", "xyz789"]) + XCTAssertEqual(copilotResumeCommand?.command, "copilot") + XCTAssertEqual(copilotResumeCommand?.arguments, ["--resume", "copilot-session"]) } /// Verifies a new workspace starts focused on its initial surface. diff --git a/Tests/ShellraiserTests/GhosttyRuntimeCommandTests.swift b/Tests/ShellraiserTests/GhosttyRuntimeCommandTests.swift index a824785..4783eb8 100644 --- a/Tests/ShellraiserTests/GhosttyRuntimeCommandTests.swift +++ b/Tests/ShellraiserTests/GhosttyRuntimeCommandTests.swift @@ -79,6 +79,35 @@ final class GhosttyRuntimeCommandTests: XCTestCase { XCTAssertFalse(command.contains("/bin/zsh")) } + /// Verifies Copilot resumes the specific persisted session rather than the CLI's latest session. + func testLaunchCommandUsesCopilotResumeCommandWhenSurfaceHasPersistedSessionId() { + let surface = SurfaceModel( + id: UUID(uuidString: "00000000-0000-0000-0000-000000000705")!, + title: "~", + agentType: .copilot, + sessionId: "4fbe8379-2298-43e8-a8e6-ae0fe9a46217", + shouldResumeSession: true, + terminalConfig: TerminalPanelConfig( + workingDirectory: "/tmp/project", + shell: "/bin/zsh", + environment: [:] + ), + isIdle: false, + hasUnreadIdleNotification: false, + hasPendingCompletion: false, + pendingCompletionSequence: nil, + lastCompletionAt: nil, + lastActivity: Date(timeIntervalSince1970: 1_700_000_100) + ) + + let command = GhosttyRuntime.launchCommand(for: surface, terminalConfig: surface.terminalConfig) + + XCTAssertTrue(command.contains("copilot")) + XCTAssertTrue(command.contains("--resume")) + XCTAssertTrue(command.contains("4fbe8379-2298-43e8-a8e6-ae0fe9a46217")) + XCTAssertFalse(command.contains("/bin/zsh")) + } + /// Verifies Claude only relaunches with resume when its persisted transcript exists on disk. func testLaunchCommandUsesClaudeResumeWhenTranscriptExists() throws { let transcriptURL = FileManager.default.temporaryDirectory diff --git a/Tests/ShellraiserTests/WorkspaceSurfaceManagerSurfaceStateTests.swift b/Tests/ShellraiserTests/WorkspaceSurfaceManagerSurfaceStateTests.swift index f3fd325..846c2ee 100644 --- a/Tests/ShellraiserTests/WorkspaceSurfaceManagerSurfaceStateTests.swift +++ b/Tests/ShellraiserTests/WorkspaceSurfaceManagerSurfaceStateTests.swift @@ -76,6 +76,44 @@ final class WorkspaceSurfaceManagerSurfaceStateTests: WorkspaceTestCase { XCTAssertEqual(updatedSurface?.sessionId, "019ce8bb-b369-7693-9be0-664a228e4e24") } + /// Verifies Copilot stores its opaque session id and clears Claude-only transcript metadata. + func testSetSessionIdentityClearsTranscriptPathForCopilot() { + let persistence = makePersistence() + let manager = WorkspaceSurfaceManager() + let surface = makeSurface( + id: UUID(uuidString: "00000000-0000-0000-0000-0000000008B1")!, + title: "Copilot Surface", + agentType: .claudeCode, + sessionId: "existing-session", + transcriptPath: "/tmp/claude-transcript.jsonl" + ) + let paneId = UUID(uuidString: "00000000-0000-0000-0000-0000000008B2")! + let workspaceId = UUID(uuidString: "00000000-0000-0000-0000-0000000008B3")! + var workspaces = [ + makeWorkspace( + id: workspaceId, + rootPane: makeLeaf(paneId: paneId, surfaces: [surface]), + focusedSurfaceId: surface.id + ) + ] + + manager.setSessionIdentity( + workspaceId: workspaceId, + surfaceId: surface.id, + agentType: .copilot, + sessionId: " 4fbe8379-2298-43e8-a8e6-ae0fe9a46217 ", + workspaces: &workspaces, + persistence: persistence + ) + + let updatedSurface = self.surface(in: workspaces[0].rootPane, surfaceId: surface.id) + XCTAssertEqual(updatedSurface?.agentType, .copilot) + XCTAssertEqual(updatedSurface?.sessionId, "4fbe8379-2298-43e8-a8e6-ae0fe9a46217") + XCTAssertEqual(updatedSurface?.transcriptPath, "") + XCTAssertTrue(updatedSurface?.shouldResumeSession ?? false) + XCTAssertEqual(persistence.load(), workspaces) + } + /// Verifies Claude transcript state is cleared when a new session arrives without a transcript path. func testSetSessionIdentityClearsClaudeTranscriptPathWhenTranscriptIsMissing() { let persistence = makePersistence() From 373abe713b8f5f2818111121c8d436723b5785dd Mon Sep 17 00:00:00 2001 From: Morten Trydal Date: Fri, 31 Jul 2026 13:36:47 +0200 Subject: [PATCH 2/3] Run Copilot hook cleanup asynchronously --- .../Agents/AgentRuntimeBridge.swift | 40 ++++++++++++++++--- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/Sources/Shellraiser/Infrastructure/Agents/AgentRuntimeBridge.swift b/Sources/Shellraiser/Infrastructure/Agents/AgentRuntimeBridge.swift index c6916de..4c93454 100644 --- a/Sources/Shellraiser/Infrastructure/Agents/AgentRuntimeBridge.swift +++ b/Sources/Shellraiser/Infrastructure/Agents/AgentRuntimeBridge.swift @@ -1,4 +1,5 @@ import Foundation +import Dispatch /// App-owned runtime bridge that installs helper binaries for agent completion hooks. @MainActor @@ -39,6 +40,7 @@ final class AgentRuntimeBridge: AgentRuntimeSupporting { self.copilotHomeURL = copilotHomeURL ?? rootURL.appendingPathComponent("copilot-home", isDirectory: true) prepareRuntimeSupport() + scheduleCopilotHookLeaseReap() } /// Ensures helper scripts and the completion event log exist. @@ -75,7 +77,6 @@ final class AgentRuntimeBridge: AgentRuntimeSupporting { named: "shellraiser-copilot-hooks", contents: copilotHookManagerContents ) - try reapCopilotHookLeases() try writeTextFile( at: zshShimDirectory.appendingPathComponent(".zshenv"), contents: zshEnvContents @@ -147,15 +148,42 @@ final class AgentRuntimeBridge: AgentRuntimeSupporting { try data.write(to: fileURL, options: .atomic) } - /// Reaps stale Copilot hook leases at startup without touching user-owned hook files. - private func reapCopilotHookLeases() throws { + /// Schedules one startup cleanup pass without delaying managed surface creation. + private func scheduleCopilotHookLeaseReap() { let managerURL = binDirectory.appendingPathComponent("shellraiser-copilot-hooks") + let runtimeDirectory = self.runtimeDirectory + let environment = ProcessInfo.processInfo.environment.merging( + ["SHELLRAISER_COPILOT_HOME": copilotHomeURL.path] + ) { _, bridgeValue in bridgeValue } + + DispatchQueue.global(qos: .utility).async { + guard FileManager.default.isExecutableFile(atPath: managerURL.path) else { + return + } + + do { + try Self.reapCopilotHookLeases( + managerURL: managerURL, + environment: environment + ) + } catch { + guard FileManager.default.fileExists(atPath: runtimeDirectory.path) else { + return + } + NSLog("Failed to reap stale Shellraiser Copilot hook leases: \(error)") + } + } + } + + /// Reaps stale Copilot hook leases without touching user-owned hook files. + nonisolated private static func reapCopilotHookLeases( + managerURL: URL, + environment: [String: String] + ) throws { let process = Process() process.executableURL = managerURL process.arguments = ["reap"] - process.environment = ProcessInfo.processInfo.environment.merging( - ["SHELLRAISER_COPILOT_HOME": copilotHomeURL.path] - ) { _, bridgeValue in bridgeValue } + process.environment = environment process.standardOutput = FileHandle.nullDevice process.standardError = FileHandle.nullDevice try process.run() From eb904ded72237b9a5683b0769924390978507628 Mon Sep 17 00:00:00 2001 From: Morten Trydal Date: Fri, 31 Jul 2026 13:37:13 +0200 Subject: [PATCH 3/3] Simplify Copilot README coverage --- README.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/README.md b/README.md index e3b44a0..6f7d872 100644 --- a/README.md +++ b/README.md @@ -50,12 +50,6 @@ Run the app locally: make run ``` -## Copilot CLI - -Shellraiser supports interactive [GitHub Copilot CLI](https://docs.github.com/en/copilot/how-tos/copilot-cli) sessions and resumes them with their saved session ID after an app restart. Install and authenticate the `copilot` command before starting a session. - -While a Copilot session is active, Shellraiser creates its own temporary user hook file to receive lifecycle events for activity and completion tracking. It leaves existing Copilot user, repository, policy, and plugin hooks unchanged. If Shellraiser stops unexpectedly, the hook remains while the Copilot process is alive; stale Shellraiser hook state is removed the next time Shellraiser or a managed Copilot session starts. - ## Automation Shellraiser exposes AppleScript objects and commands for workspaces, terminals, and surface configurations.