Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# gh-copilot-codespace

Launch Copilot CLI with all file/bash operations executing on remote GitHub Codespace(s) via SSH. Supports multiple codespaces, session resume, and on-demand codespace creation.
Launch Copilot CLI with all file/bash operations executing on remote GitHub Codespace(s) via SSH. Supports multiple codespaces, session resume, on-demand codespace creation, and an opt-in headless delegate lane for autonomous remote Copilot work.

## How it works

Expand All @@ -16,7 +16,8 @@ A single Go binary (`gh-copilot-codespace`) serves four roles:
- `remote_write_bash`, `remote_read_bash`, `remote_stop_bash`, `remote_list_bash` — async session management (tmux-based)
- `remote_cd`, `remote_cwd` — default working directory navigation
- `list_codespaces`, `create_codespace`, `connect_codespace`, `delete_codespace` — codespace lifecycle
- `open_shell` — open interactive SSH session
- `open_shell` — open interactive SSH session
- Optional extra: `delegate_task`, `read_delegate_task`, `cancel_delegate_task` — run and manage remote headless Copilot delegate tasks

3. **Exec agent** (`gh-copilot-codespace exec`) — Deployed to the codespace at startup. Provides structured command execution with workdir/env setup, replacing fragile shell escaping in SSH forwarding.

Expand Down Expand Up @@ -62,6 +63,9 @@ gh copilot-codespace --name my-session
# Resume a previous session
gh copilot-codespace --resume my-session

# Enable the headless delegate extra
gh copilot-codespace --headless-delegate -c my-codespace

# List workspace sessions
gh copilot-codespace workspaces

Expand Down Expand Up @@ -100,6 +104,16 @@ For `remote_bash`, `remote_grep`, and `remote_glob`, prefer passing `cwd` explic

The agent can also create, connect to, and delete codespaces on the fly using `create_codespace`, `connect_codespace`, and `delete_codespace` tools. Starting with zero connected codespaces is supported, so you can bootstrap a brand-new session and create the first codespace from inside the agent.

## Headless delegate extra

Pass `--headless-delegate` to enable an additive delegate lane behind the MCP bridge. This exposes:

- `delegate_task` — start a background remote Copilot worker on a codespace
- `read_delegate_task` — read progress and final output
- `cancel_delegate_task` — stop a running delegate task

The delegate lane is opt-in and leaves the default `remote_*` workflow unchanged.

## Session resume

Workspace sessions are saved to `~/.copilot/workspaces/` with a manifest (`workspace.json`) tracking connected codespaces. Empty sessions are resumable too, which is useful when you want to launch first and create/connect codespaces later from the agent. Use `--resume` to reconnect:
Expand Down
2 changes: 1 addition & 1 deletion cmd/gh-copilot-codespace/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ func TestIntegration_MCPConfigRewriting(t *testing.T) {
}

// Verify buildMCPConfig rewrites it to use gh
mcpConfig := buildMCPConfig("/usr/local/bin/self", cs, wd, remoteMCP, "")
mcpConfig := buildMCPConfig("/usr/local/bin/self", cs, wd, remoteMCP, "", false)
var parsed map[string]any
if err := json.Unmarshal([]byte(mcpConfig), &parsed); err != nil {
t.Fatalf("invalid merged MCP config JSON: %v", err)
Expand Down
154 changes: 122 additions & 32 deletions cmd/gh-copilot-codespace/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"syscall"
"time"

"github.com/ekroon/gh-copilot-codespace/internal/delegate"
"github.com/ekroon/gh-copilot-codespace/internal/mcp"
"github.com/ekroon/gh-copilot-codespace/internal/registry"
"github.com/ekroon/gh-copilot-codespace/internal/ssh"
Expand All @@ -42,6 +43,7 @@ Flags:
--name SESSION Name for the local workspace session
--resume SESSION Re-attach to a previous workspace session
--local-tools Keep all local tools (bash, grep, glob) enabled alongside remote_* tools
--headless-delegate Enable the opt-in headless delegate extra (delegate_task/read_delegate_task/cancel_delegate_task)

Subcommands:
mcp Run as MCP server (used internally by Copilot)
Expand Down Expand Up @@ -128,7 +130,12 @@ func runMCPServer() {
}
}

mcpServer := mcp.NewServer(reg)
var cfg mcp.LifecycleConfig
if headlessDelegateEnabled() {
cfg.DelegateManager = delegate.NewManager(delegate.NewHeadlessRunner())
}

mcpServer := mcp.NewServer(reg, cfg)

log.SetOutput(os.Stderr)
log.Printf("codespace-mcp: starting with %d codespace(s)", reg.Len())
Expand Down Expand Up @@ -186,14 +193,19 @@ func registryFromEntries(ctx context.Context, entries []registryEntry, build fun
return reg, nil
}

func headlessDelegateEnabled() bool {
return os.Getenv("CODESPACE_ENABLE_HEADLESS_DELEGATE") == "1"
}

type launcherOptions struct {
codespaceNames []string
noCodespace bool
workdirOverride string
sessionName string
resumeSession string
localTools bool
copilotArgs []string
codespaceNames []string
noCodespace bool
workdirOverride string
sessionName string
resumeSession string
localTools bool
headlessDelegate bool
copilotArgs []string
}

func parseLauncherArgs(args []string) (launcherOptions, error) {
Expand All @@ -202,6 +214,8 @@ func parseLauncherArgs(args []string) (launcherOptions, error) {
switch {
case args[i] == "--local-tools":
opts.localTools = true
case args[i] == "--headless-delegate":
opts.headlessDelegate = true
case args[i] == "--no-codespace":
opts.noCodespace = true
case (args[i] == "--codespace" || args[i] == "-c") && i+1 < len(args):
Expand Down Expand Up @@ -245,7 +259,7 @@ func runLauncher(args []string) error {

// Handle --resume: load workspace and reconnect to codespaces
if opts.resumeSession != "" {
return runResume(opts.resumeSession, opts.copilotArgs)
return runResume(opts.resumeSession, opts.copilotArgs, opts.headlessDelegate)
}

// The binary serves as both launcher and MCP server
Expand Down Expand Up @@ -351,16 +365,16 @@ func runLauncher(args []string) error {

// Prepend codespace context to copilot-instructions.md
if reg.Len() > 1 {
writeMultiCodespaceInstructionsPreamble(instructionsDir, reg)
writeMultiCodespaceInstructionsPreamble(instructionsDir, reg, opts.headlessDelegate)
} else {
writeCodespaceInstructionsPreamble(instructionsDir, firstWorkdir)
writeCodespaceInstructionsPreamble(instructionsDir, firstWorkdir, opts.headlessDelegate)
}
} else {
if wsErr != nil {
return fmt.Errorf("creating workspace: %w", wsErr)
}
instructionsDir = ws.Dir
writeZeroCodespaceInstructionsPreamble(instructionsDir)
writeZeroCodespaceInstructionsPreamble(instructionsDir, opts.headlessDelegate)
fmt.Println("No codespaces selected. Start with create_codespace or connect_codespace from the agent.")
}

Expand All @@ -384,14 +398,17 @@ func runLauncher(args []string) error {

// Generate remote-explorer custom agent for codespace file exploration
generateRemoteExplorerAgent(instructionsDir)
if opts.headlessDelegate {
generateRemoteDelegateAgent(instructionsDir)
}

// Change to the instructions dir so copilot finds the instruction files
if err := os.Chdir(instructionsDir); err != nil {
return fmt.Errorf("changing to instructions dir: %w", err)
}

// Build MCP config with registry serialization for multi-CS support
mcpConfig := buildMCPConfigWithRegistry(self, reg, allRemoteMCPServers)
mcpConfig := buildMCPConfigWithRegistry(self, reg, allRemoteMCPServers, opts.headlessDelegate)

// Excluded tools
var excludedTools []string
Expand Down Expand Up @@ -836,7 +853,7 @@ func parseMCPConfigJSON(content []byte) map[string]any {
// writeCodespaceInstructionsPreamble prepends a codespace-context section to the
// copilot-instructions.md in the mirror dir. If the file doesn't exist, it creates it.
// This tells the agent how to route between local and remote tools.
func writeCodespaceInstructionsPreamble(mirrorDir, workdir string) {
func writeCodespaceInstructionsPreamble(mirrorDir, workdir string, headlessDelegate bool) {
preamble := fmt.Sprintf(`# Codespace Remote Development

You are working on a remote GitHub Codespace. Source code lives on the codespace at %s, NOT locally.
Expand All @@ -849,6 +866,15 @@ You are working on a remote GitHub Codespace. Source code lives on the codespace
- **Exploring the codebase**: delegate to @remote-explorer instead of the built-in explore agent (the built-in explore agent cannot access remote files)

`, workdir)
if headlessDelegate {
preamble += `## Delegate lane

- Use ` + "`delegate_task`" + ` to start an autonomous remote Copilot worker on the codespace.
- Use ` + "`read_delegate_task`" + ` and ` + "`cancel_delegate_task`" + ` to inspect or stop delegate runs.
- Delegate to @remote-delegate when you want a high-level remote worker instead of the direct remote_* tool lane.

`
}

instructionsPath := filepath.Join(mirrorDir, ".github", "copilot-instructions.md")
if err := os.MkdirAll(filepath.Dir(instructionsPath), 0o755); err != nil {
Expand Down Expand Up @@ -924,17 +950,22 @@ func ensureTrustedFolder(dir string) error {
return os.WriteFile(configPath, out, 0o644)
}

func buildMCPConfig(selfBinary, codespaceName, workdir string, remoteMCPServers map[string]any, remoteBinary string) string {
func buildMCPConfig(selfBinary, codespaceName, workdir string, remoteMCPServers map[string]any, remoteBinary string, headlessDelegate bool) string {
env := map[string]string{
"CODESPACE_NAME": codespaceName,
"CODESPACE_WORKDIR": workdir,
}
if headlessDelegate {
env["CODESPACE_ENABLE_HEADLESS_DELEGATE"] = "1"
}

servers := map[string]any{
"codespace": map[string]any{
"type": "local",
"command": selfBinary,
"args": []string{"mcp"},
"env": map[string]string{
"CODESPACE_NAME": codespaceName,
"CODESPACE_WORKDIR": workdir,
},
"tools": []string{"*"},
"env": env,
"tools": []string{"*"},
},
}

Expand All @@ -960,7 +991,7 @@ func buildMCPConfig(selfBinary, codespaceName, workdir string, remoteMCPServers

// buildMCPConfigWithRegistry creates the MCP config JSON using the full registry.
// Uses CODESPACE_REGISTRY env var (JSON array) for zero-, single-, or multi-codespace support.
func buildMCPConfigWithRegistry(selfBinary string, reg *registry.Registry, remoteMCPServers map[string]any) string {
func buildMCPConfigWithRegistry(selfBinary string, reg *registry.Registry, remoteMCPServers map[string]any, headlessDelegate bool) string {
// Serialize registry entries for the MCP server process
var entries []registryEntry
for _, cs := range reg.All() {
Expand All @@ -973,16 +1004,20 @@ func buildMCPConfigWithRegistry(selfBinary string, reg *registry.Registry, remot
})
Comment on lines 995 to 1004
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

buildMCPConfigWithRegistry serializes Alias/Name/Repo/Branch/Workdir but not ManagedCodespace.ExecAgent. As a result, the MCP server process reconstructed from CODESPACE_REGISTRY can't pass an exec-agent path into delegate_task (and will always fall back to the bash -lc path), even if the launcher successfully deployed the exec agent. Consider including ExecAgent in the serialized registryEntry and wiring it through registryFromJSON.

See below for a potential fix:

			Workdir:    cs.Workdir,
			ExecAgent:  cs.ExecAgent,

Copilot uses AI. Check for mistakes.
}
registryJSON, _ := json.Marshal(entries)
env := map[string]string{
"CODESPACE_REGISTRY": string(registryJSON),
}
if headlessDelegate {
env["CODESPACE_ENABLE_HEADLESS_DELEGATE"] = "1"
}

servers := map[string]any{
"codespace": map[string]any{
"type": "local",
"command": selfBinary,
"args": []string{"mcp"},
"env": map[string]string{
"CODESPACE_REGISTRY": string(registryJSON),
},
"tools": []string{"*"},
"env": env,
"tools": []string{"*"},
},
}

Expand Down Expand Up @@ -1010,7 +1045,7 @@ func buildMCPConfigWithRegistry(selfBinary string, reg *registry.Registry, remot
}

// writeMultiCodespaceInstructionsPreamble writes a preamble listing all connected codespaces.
func writeMultiCodespaceInstructionsPreamble(mirrorDir string, reg *registry.Registry) {
func writeMultiCodespaceInstructionsPreamble(mirrorDir string, reg *registry.Registry, headlessDelegate bool) {
var sb strings.Builder
sb.WriteString("# Multi-Codespace Remote Development\n\n")
sb.WriteString("You are connected to multiple remote GitHub Codespaces. Source code lives on the codespaces, NOT locally.\n\n")
Expand All @@ -1031,6 +1066,12 @@ func writeMultiCodespaceInstructionsPreamble(mirrorDir string, reg *registry.Reg
sb.WriteString("- **Shell commands**: use `remote_bash` with the `codespace` parameter\n")
sb.WriteString("- For `remote_bash`, `remote_grep`, and `remote_glob`, pass `cwd` explicitly when you need parallel-safe or targeted execution; `remote_cd` only changes the default cwd for later sequential calls.\n")
sb.WriteString("- **Exploring the codebase**: delegate to @remote-explorer instead of the built-in explore agent\n\n")
if headlessDelegate {
sb.WriteString("## Delegate lane\n\n")
sb.WriteString("- Use `delegate_task` for longer autonomous work on a selected codespace.\n")
sb.WriteString("- Use `read_delegate_task` and `cancel_delegate_task` to inspect or stop a delegate run.\n")
sb.WriteString("- Delegate to @remote-delegate when you want a remote Copilot worker to plan and execute work on the codespace.\n\n")
}

instructionsPath := filepath.Join(mirrorDir, ".github", "copilot-instructions.md")
if err := os.MkdirAll(filepath.Dir(instructionsPath), 0o755); err != nil {
Expand All @@ -1047,7 +1088,7 @@ func writeMultiCodespaceInstructionsPreamble(mirrorDir string, reg *registry.Reg

// writeZeroCodespaceInstructionsPreamble bootstraps a local session before any
// codespace has been connected, so the agent knows to use lifecycle tools first.
func writeZeroCodespaceInstructionsPreamble(mirrorDir string) {
func writeZeroCodespaceInstructionsPreamble(mirrorDir string, headlessDelegate bool) {
preamble := `# Codespace Lifecycle Session

You are not connected to any remote GitHub Codespaces yet, so project source code is not available locally.
Expand All @@ -1061,6 +1102,14 @@ You are not connected to any remote GitHub Codespaces yet, so project source cod
- **Local session files** (plan.md, session state, notes under ~/.copilot/): use the built-in local tools (view, edit, create).

`
if headlessDelegate {
preamble += `## Optional delegate lane

- After a codespace is connected, use ` + "`delegate_task`" + ` for longer autonomous remote Copilot work.
- Use ` + "`read_delegate_task`" + ` and ` + "`cancel_delegate_task`" + ` to inspect or stop delegate runs.

`
}

instructionsPath := filepath.Join(mirrorDir, ".github", "copilot-instructions.md")
if err := os.MkdirAll(filepath.Dir(instructionsPath), 0o755); err != nil {
Expand Down Expand Up @@ -1417,8 +1466,46 @@ Use these remote tools to explore the codespace:
os.WriteFile(filepath.Join(agentsDir, "remote-explorer.agent.md"), []byte(agent), 0o644)
}

func generateRemoteDelegateAgent(mirrorDir string) {
agentsDir := filepath.Join(mirrorDir, ".github", "agents")
if err := os.MkdirAll(agentsDir, 0o755); err != nil {
return
}

agent := `---
name: remote-delegate
description: >-
Run longer autonomous tasks on a remote codespace via the delegate_task tool family.
Use this agent when you want a remote Copilot worker to plan and execute work with less
frontend context churn than direct step-by-step tool use.
model: claude-sonnet-4.5
tools:
- codespace/*
- read
- search
---

You orchestrate autonomous work on remote GitHub Codespaces.

## Workflow

1. Use list_codespaces to confirm the target alias when more than one codespace is connected.
2. Start the remote worker with delegate_task.
3. Poll progress with read_delegate_task.
4. Cancel with cancel_delegate_task if the task is clearly going in the wrong direction.

## Guidelines

- Prefer delegate_task for multi-step implementation or review work that should stay mostly remote.
- Keep prompts explicit about codespace, cwd, and expected outcome.
- Use direct remote_* tools when you need precise manual control or small surgical edits.
`

os.WriteFile(filepath.Join(agentsDir, "remote-delegate.agent.md"), []byte(agent), 0o644)
}

// runResume loads a workspace session and reconnects to its codespaces.
func runResume(sessionName string, copilotArgs []string) error {
func runResume(sessionName string, copilotArgs []string, headlessDelegate bool) error {
ws, err := workspace.Load(sessionName)
if err != nil {
return fmt.Errorf("loading workspace %q: %w", sessionName, err)
Expand Down Expand Up @@ -1478,25 +1565,28 @@ func runResume(sessionName string, copilotArgs []string) error {
fetchInstructionFiles(primary.Executor.(*ssh.Client), primary.Name, primary.Workdir, remoteBinary)

if reg.Len() > 1 {
writeMultiCodespaceInstructionsPreamble(instructionsDir, reg)
writeMultiCodespaceInstructionsPreamble(instructionsDir, reg, headlessDelegate)
} else {
writeCodespaceInstructionsPreamble(instructionsDir, primary.Workdir)
writeCodespaceInstructionsPreamble(instructionsDir, primary.Workdir, headlessDelegate)
}
} else {
writeZeroCodespaceInstructionsPreamble(instructionsDir)
writeZeroCodespaceInstructionsPreamble(instructionsDir, headlessDelegate)
}

if err := ensureTrustedFolder(instructionsDir); err != nil {
fmt.Fprintf(os.Stderr, "Warning: could not auto-trust directory: %v\n", err)
}

generateRemoteExplorerAgent(instructionsDir)
if headlessDelegate {
generateRemoteDelegateAgent(instructionsDir)
}

if err := os.Chdir(instructionsDir); err != nil {
return fmt.Errorf("changing to workspace dir: %w", err)
}

mcpConfig := buildMCPConfigWithRegistry(self, reg, nil)
mcpConfig := buildMCPConfigWithRegistry(self, reg, nil, headlessDelegate)

excludedTools := []string{
"bash", "write_bash", "read_bash",
Expand Down
Loading