sketch: "git push" button

Ultimately, we want to allow users to push their changes to github, and
thereby do a good chunk of work without resorting to the terminal (and
figuring out how to move the git references around, which requires a
bunch of esotiric and annoying expertise).

This commit introduces:

1. For outtie's HTTP server (which is now comically Go HTTP ->
   CGI-effing-bin -> git -> shell script -> git in this case), there's a
   custom git hook that forwards changes to refs/remotes/origin/foo to
   origin/foo. This is a git proxy of sorts. By forwarding the
   SSH_AUTH_SOCK, we can use outtie's auth options without giving innie
   the actual credentials. This works by creating a temporary directory
   for git hooks (for outtie).

2. Innie sets up a new remote, "upstream" when a "passthrough-upstream"
   flag is pasksed. This remote kind of looks like the real upstream (so
   upstream/foo) is fetched. This will let the agent handle rebases
   better.

3. Innie exposes a /pushinfo handler that returns the list of remotes
   and the current commit and such. These have nice display names for
   the outtie's machine and github if useful.

   There's also a /push handler. This is the thing that knows about the
   refs/remotes/origin/foo thing. There's no magic git push refspec that
   makes this all work without that, I think. (Maybe there is? I don't
   think there is.)

   Note that there's been some changes about what the remotes look like,
   and when we use the remotes and when we use agent.GitOrigin().
   We may be able to simplify this by using git's insteadof
   configurations, but I think it's fine.

4. The web UI exposes a button to push, choose the remote and branch,
   and such. If it can't do the push, you'll get a button to try to get
   the agent to rebase.

   We don't allow force pushes in the UI. We're treating those
   as an advanced feature, and, if you need to do that, you can
   figure it out.

This was collaboration with a gazillion sketch sessions.
diff --git a/cmd/go2ts/go2ts.go b/cmd/go2ts/go2ts.go
index 18a890e..e05a7ea 100644
--- a/cmd/go2ts/go2ts.go
+++ b/cmd/go2ts/go2ts.go
@@ -60,6 +60,10 @@
 		server.State{},
 		server.TodoItem{},
 		server.TodoList{},
+		server.Remote{},
+		server.GitPushInfoResponse{},
+		server.GitPushRequest{},
+		server.GitPushResponse{},
 		loop.MultipleChoiceOption{},
 		loop.MultipleChoiceParams{},
 		git_tools.DiffFile{},
diff --git a/cmd/sketch/main.go b/cmd/sketch/main.go
index 41d0947..ae31afc 100644
--- a/cmd/sketch/main.go
+++ b/cmd/sketch/main.go
@@ -262,6 +262,7 @@
 	bashFastTimeout       string
 	bashSlowTimeout       string
 	bashBackgroundTimeout string
+	passthroughUpstream   bool
 }
 
 // parseCLIFlags parses all command-line flags and returns a CLIFlags struct
@@ -324,6 +325,7 @@
 	internalFlags.StringVar(&flags.outsideHTTP, "outside-http", "", "(internal) host for outside sketch")
 	internalFlags.BoolVar(&flags.linkToGitHub, "link-to-github", false, "(internal) enable GitHub branch linking in UI")
 	internalFlags.StringVar(&flags.sshConnectionString, "ssh-connection-string", "", "(internal) SSH connection string for connecting to the container")
+	internalFlags.BoolVar(&flags.passthroughUpstream, "passthrough-upstream", false, "(internal) configure upstream remote for passthrough to innie")
 
 	// Developer flags
 	internalFlags.StringVar(&flags.httprrFile, "httprr", "", "if set, record HTTP interactions to file")
@@ -473,16 +475,17 @@
 		OneShot:           flags.oneShot,
 		Prompt:            flags.prompt,
 
-		Verbose:        flags.verbose,
-		DockerArgs:     flags.dockerArgs,
-		Mounts:         flags.mounts,
-		ExperimentFlag: flags.experimentFlag.String(),
-		TermUI:         flags.termUI,
-		MaxDollars:     flags.maxDollars,
-		BranchPrefix:   flags.branchPrefix,
-		LinkToGitHub:   flags.linkToGitHub,
-		SubtraceToken:  flags.subtraceToken,
-		MCPServers:     flags.mcpServers,
+		Verbose:             flags.verbose,
+		DockerArgs:          flags.dockerArgs,
+		Mounts:              flags.mounts,
+		ExperimentFlag:      flags.experimentFlag.String(),
+		TermUI:              flags.termUI,
+		MaxDollars:          flags.maxDollars,
+		BranchPrefix:        flags.branchPrefix,
+		LinkToGitHub:        flags.linkToGitHub,
+		SubtraceToken:       flags.subtraceToken,
+		MCPServers:          flags.mcpServers,
+		PassthroughUpstream: flags.passthroughUpstream,
 	}
 
 	if err := dockerimg.LaunchContainer(ctx, config); err != nil {
@@ -629,6 +632,7 @@
 		LinkToGitHub:        flags.linkToGitHub,
 		SSHConnectionString: flags.sshConnectionString,
 		MCPServers:          flags.mcpServers,
+		PassthroughUpstream: flags.passthroughUpstream,
 	}
 
 	// Parse timeout configuration
diff --git a/dear_llm.md b/dear_llm.md
index 72156a4..f51898b 100644
--- a/dear_llm.md
+++ b/dear_llm.md
@@ -38,3 +38,9 @@
 ## Testing
 
 - Do NOT use the testify package. Write tests using the standard Go testing library only.
+
+## Frontend Guidelines
+
+- Always use relative URLs in frontend code (e.g., "./git/head" instead of "/git/head") to ensure proper routing through proxies.
+- Always use Go structs for input and output types of JSON HTTP requests and use go2ts
+  to convert them to typescript.
diff --git a/dockerimg/dockerimg.go b/dockerimg/dockerimg.go
index 16865cf..19d90cf 100644
--- a/dockerimg/dockerimg.go
+++ b/dockerimg/dockerimg.go
@@ -136,6 +136,9 @@
 
 	// MCPServers contains MCP server configurations
 	MCPServers []string
+
+	// PassthroughUpstream configures upstream remote for passthrough to innie
+	PassthroughUpstream bool
 }
 
 // LaunchContainer creates a docker container for a project, installs sketch and opens a connection to it.
@@ -176,6 +179,11 @@
 	// Capture the original git origin URL before we set up the temporary git server
 	config.OriginalGitOrigin = getOriginalGitOrigin(ctx, gitRoot)
 
+	// If we've got an upstream, let's configure
+	if config.OriginalGitOrigin != "" {
+		config.PassthroughUpstream = true
+	}
+
 	imgName, err := findOrBuildDockerImage(ctx, gitRoot, config.BaseImage, config.ForceRebuild, config.Verbose)
 	if err != nil {
 		return err
@@ -200,7 +208,7 @@
 	errCh := make(chan error)
 
 	// Start the git server
-	gitSrv, err := newGitServer(gitRoot)
+	gitSrv, err := newGitServer(gitRoot, config.PassthroughUpstream)
 	if err != nil {
 		return fmt.Errorf("failed to start git server: %w", err)
 	}
@@ -480,7 +488,7 @@
 	return gs.srv.Serve(gs.gitLn)
 }
 
-func newGitServer(gitRoot string) (*gitServer, error) {
+func newGitServer(gitRoot string, configureUpstreamPassthrough bool) (*gitServer, error) {
 	ret := &gitServer{
 		pass: rand.Text(),
 	}
@@ -499,7 +507,15 @@
 		}
 	}()
 
-	srv := http.Server{Handler: &gitHTTP{gitRepoRoot: gitRoot, pass: []byte(ret.pass), browserC: browserC}}
+	var hooksDir string
+	if configureUpstreamPassthrough {
+		hooksDir, err = setupHooksDir(gitRoot)
+		if err != nil {
+			return nil, fmt.Errorf("failed to setup hooks directory: %w", err)
+		}
+	}
+
+	srv := http.Server{Handler: &gitHTTP{gitRepoRoot: gitRoot, hooksDir: hooksDir, pass: []byte(ret.pass), browserC: browserC}}
 	ret.srv = &srv
 
 	_, gitPort, err := net.SplitHostPort(gitLn.Addr().String())
@@ -625,6 +641,9 @@
 	for _, mcpServer := range config.MCPServers {
 		cmdArgs = append(cmdArgs, "-mcp", mcpServer)
 	}
+	if config.PassthroughUpstream {
+		cmdArgs = append(cmdArgs, "-passthrough-upstream")
+	}
 
 	// Add additional docker arguments if provided
 	if config.DockerArgs != "" {
diff --git a/dockerimg/githttp.go b/dockerimg/githttp.go
index ecd46a1..acb099b 100644
--- a/dockerimg/githttp.go
+++ b/dockerimg/githttp.go
@@ -3,6 +3,7 @@
 import (
 	"context"
 	"crypto/subtle"
+	_ "embed"
 	"fmt"
 	"log/slog"
 	"net/http"
@@ -15,12 +16,45 @@
 	"time"
 )
 
+//go:embed pre-receive.sh
+var preReceiveScript string
+
 type gitHTTP struct {
 	gitRepoRoot string
+	hooksDir    string
 	pass        []byte
 	browserC    chan bool // browser launch requests
 }
 
+// setupHooksDir creates a temporary directory with git hooks for this session.
+//
+// This automatically forwards pushes from refs/remotes/origin/Y to origin/Y
+// when users push to remote tracking refs through the git HTTP backend.
+//
+// How it works:
+//  1. User pushes to refs/remotes/origin/feature-branch
+//  2. Pre-receive hook detects the pattern and extracts branch name
+//  3. Hook checks if it's a force push (declined if so)
+//  4. Hook runs "git push origin <commit>:feature-branch"
+//  5. If origin push fails, user's push also fails
+//
+// Note:
+//   - Error propagation from origin push to user push
+//   - Session isolation with temporary hooks directory
+func setupHooksDir(gitRepoRoot string) (string, error) {
+	hooksDir, err := os.MkdirTemp("", "sketch-git-hooks-*")
+	if err != nil {
+		return "", fmt.Errorf("failed to create hooks directory: %w", err)
+	}
+
+	preReceiveHook := filepath.Join(hooksDir, "pre-receive")
+	if err := os.WriteFile(preReceiveHook, []byte(preReceiveScript), 0o755); err != nil {
+		return "", fmt.Errorf("failed to write pre-receive hook: %w", err)
+	}
+
+	return hooksDir, nil
+}
+
 func (g *gitHTTP) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	defer func() {
 		if err := recover(); err != nil {
@@ -116,9 +150,16 @@
 	}
 
 	w.Header().Set("Cache-Control", "no-cache")
+
+	args := []string{"http-backend"}
+	if g.hooksDir != "" {
+		// Use -c flag to set core.hooksPath for this git command only
+		args = []string{"-c", "core.hooksPath=" + g.hooksDir, "http-backend"}
+	}
+
 	h := &cgi.Handler{
 		Path: gitBin,
-		Args: []string{"http-backend"},
+		Args: args,
 		Dir:  g.gitRepoRoot,
 		Env: []string{
 			"GIT_PROJECT_ROOT=" + g.gitRepoRoot,
@@ -129,6 +170,9 @@
 			"GIT_HTTP_ALLOW_REPACK=true",
 			"GIT_HTTP_ALLOW_PUSH=true",
 			"GIT_HTTP_VERBOSE=1",
+			// We need to pass through the SSH auth sock to the CGI script
+			// so that we can use the user's existing SSH key infra to authenticate.
+			"SSH_AUTH_SOCK=" + os.Getenv("SSH_AUTH_SOCK"),
 		},
 	}
 	h.ServeHTTP(w, r)
diff --git a/dockerimg/githttp_test.go b/dockerimg/githttp_test.go
new file mode 100644
index 0000000..95a565a
--- /dev/null
+++ b/dockerimg/githttp_test.go
@@ -0,0 +1,230 @@
+package dockerimg
+
+import (
+	"os"
+	"os/exec"
+	"path/filepath"
+	"testing"
+)
+
+func TestSetupHooksDir(t *testing.T) {
+	// Create a temporary git repository
+	tmpDir, err := os.MkdirTemp("", "test-git-repo-*")
+	if err != nil {
+		t.Fatalf("failed to create temp dir: %v", err)
+	}
+	defer os.RemoveAll(tmpDir)
+
+	// Test setupHooksDir function
+	hooksDir, err := setupHooksDir(tmpDir)
+	if err != nil {
+		t.Fatalf("setupHooksDir failed: %v", err)
+	}
+	defer os.RemoveAll(hooksDir)
+
+	// Verify hooks directory was created
+	if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
+		t.Errorf("hooks directory was not created: %s", hooksDir)
+	}
+
+	// Verify pre-receive hook was created
+	preReceiveHook := filepath.Join(hooksDir, "pre-receive")
+	if _, err := os.Stat(preReceiveHook); os.IsNotExist(err) {
+		t.Errorf("pre-receive hook was not created: %s", preReceiveHook)
+	}
+
+	// Verify pre-receive hook is executable
+	info, err := os.Stat(preReceiveHook)
+	if err != nil {
+		t.Errorf("failed to stat pre-receive hook: %v", err)
+	}
+	if info.Mode()&0o111 == 0 {
+		t.Errorf("pre-receive hook is not executable")
+	}
+
+	// Verify pre-receive hook contains expected content
+	content, err := os.ReadFile(preReceiveHook)
+	if err != nil {
+		t.Errorf("failed to read pre-receive hook: %v", err)
+	}
+
+	// Check for key elements in the script
+	contentStr := string(content)
+	if !containsAll(contentStr, []string{
+		"#!/bin/bash",
+		"refs/remotes/origin/",
+		"git push origin",
+		"Force pushes are not allowed",
+		"git merge-base --is-ancestor",
+	}) {
+		t.Errorf("pre-receive hook missing expected content")
+	}
+}
+
+// Helper function to check if a string contains all substrings
+func containsAll(str string, substrings []string) bool {
+	for _, substr := range substrings {
+		if !contains(str, substr) {
+			return false
+		}
+	}
+	return true
+}
+
+// Helper function to check if a string contains a substring
+func contains(str, substr string) bool {
+	return len(str) >= len(substr) && findIndex(str, substr) >= 0
+}
+
+// Helper function to find index of substring (simple implementation)
+func findIndex(str, substr string) int {
+	for i := 0; i <= len(str)-len(substr); i++ {
+		if str[i:i+len(substr)] == substr {
+			return i
+		}
+	}
+	return -1
+}
+
+func TestPreReceiveHookScript(t *testing.T) {
+	// Create a temporary git repository
+	tmpDir, err := os.MkdirTemp("", "test-git-repo-*")
+	if err != nil {
+		t.Fatalf("failed to create temp dir: %v", err)
+	}
+	defer os.RemoveAll(tmpDir)
+
+	// Set up hooks directory
+	hooksDir, err := setupHooksDir(tmpDir)
+	if err != nil {
+		t.Fatalf("setupHooksDir failed: %v", err)
+	}
+	defer os.RemoveAll(hooksDir)
+
+	// Read the pre-receive hook script
+	preReceiveHook := filepath.Join(hooksDir, "pre-receive")
+	script, err := os.ReadFile(preReceiveHook)
+	if err != nil {
+		t.Fatalf("failed to read pre-receive hook: %v", err)
+	}
+
+	// Verify the script contains the expected bash shebang
+	scriptContent := string(script)
+	if !contains(scriptContent, "#!/bin/bash") {
+		t.Errorf("pre-receive hook missing bash shebang")
+	}
+
+	// Verify the script contains logic to handle refs/remotes/origin/Y
+	if !contains(scriptContent, "refs/remotes/origin/(.+)") {
+		t.Errorf("pre-receive hook missing refs/remotes/origin pattern matching")
+	}
+
+	// Verify the script contains force-push protection
+	if !contains(scriptContent, "git merge-base --is-ancestor") {
+		t.Errorf("pre-receive hook missing force-push protection")
+	}
+
+	// Verify the script contains git push origin command
+	if !contains(scriptContent, "git push origin") {
+		t.Errorf("pre-receive hook missing git push origin command")
+	}
+
+	// Verify the script exits with proper error codes
+	if !contains(scriptContent, "exit 1") {
+		t.Errorf("pre-receive hook missing error exit code")
+	}
+
+	if !contains(scriptContent, "exit 0") {
+		t.Errorf("pre-receive hook missing success exit code")
+	}
+}
+
+func TestGitHTTPHooksConfiguration(t *testing.T) {
+	// Create a temporary git repository
+	tmpDir, err := os.MkdirTemp("", "test-git-repo-*")
+	if err != nil {
+		t.Fatalf("failed to create temp dir: %v", err)
+	}
+	defer os.RemoveAll(tmpDir)
+
+	// Initialize git repository
+	if err := runGitCommand(tmpDir, "init"); err != nil {
+		t.Fatalf("failed to init git repo: %v", err)
+	}
+
+	// Set up hooks directory
+	hooksDir, err := setupHooksDir(tmpDir)
+	if err != nil {
+		t.Fatalf("setupHooksDir failed: %v", err)
+	}
+	defer os.RemoveAll(hooksDir)
+
+	// Create gitHTTP instance
+	gitHTTP := &gitHTTP{
+		gitRepoRoot: tmpDir,
+		hooksDir:    hooksDir,
+		pass:        []byte("test-pass"),
+		browserC:    make(chan bool, 1),
+	}
+
+	// Test that the gitHTTP struct has the hooks directory set
+	if gitHTTP.hooksDir == "" {
+		t.Errorf("gitHTTP hooksDir is empty")
+	}
+
+	if gitHTTP.hooksDir != hooksDir {
+		t.Errorf("gitHTTP hooksDir mismatch: expected %s, got %s", hooksDir, gitHTTP.hooksDir)
+	}
+
+	// Verify that the hooks directory exists and contains the pre-receive hook
+	preReceiveHook := filepath.Join(hooksDir, "pre-receive")
+	if _, err := os.Stat(preReceiveHook); os.IsNotExist(err) {
+		t.Errorf("pre-receive hook missing: %s", preReceiveHook)
+	}
+}
+
+// Helper function to run git commands
+func runGitCommand(dir string, args ...string) error {
+	cmd := exec.Command("git", args...)
+	cmd.Dir = dir
+	return cmd.Run()
+}
+
+func TestPreReceiveHookExecution(t *testing.T) {
+	// Create a temporary git repository
+	tmpDir, err := os.MkdirTemp("", "test-git-repo-*")
+	if err != nil {
+		t.Fatalf("failed to create temp dir: %v", err)
+	}
+	defer os.RemoveAll(tmpDir)
+
+	// Initialize git repository
+	if err := runGitCommand(tmpDir, "init"); err != nil {
+		t.Fatalf("failed to init git repo: %v", err)
+	}
+
+	// Set up hooks directory
+	hooksDir, err := setupHooksDir(tmpDir)
+	if err != nil {
+		t.Fatalf("setupHooksDir failed: %v", err)
+	}
+	defer os.RemoveAll(hooksDir)
+
+	// Test the hook script syntax by running bash -n on it
+	preReceiveHook := filepath.Join(hooksDir, "pre-receive")
+	cmd := exec.Command("bash", "-n", preReceiveHook)
+	if err := cmd.Run(); err != nil {
+		t.Errorf("pre-receive hook has syntax errors: %v", err)
+	}
+
+	// Test that the hook script is executable
+	info, err := os.Stat(preReceiveHook)
+	if err != nil {
+		t.Fatalf("failed to stat pre-receive hook: %v", err)
+	}
+
+	mode := info.Mode()
+	if mode&0o111 == 0 {
+		t.Errorf("pre-receive hook is not executable: mode = %v", mode)
+	}
+}
diff --git a/dockerimg/pre-receive.sh b/dockerimg/pre-receive.sh
new file mode 100644
index 0000000..dfabb64
--- /dev/null
+++ b/dockerimg/pre-receive.sh
@@ -0,0 +1,83 @@
+#!/bin/bash
+# Pre-receive hook for sketch git http server
+# Handles refs/remotes/origin/Y pushes by forwarding them to origin/Y
+
+set -e
+
+# Timeout function for commands (macOS compatible)
+# Usage: run_with_timeout <timeout_seconds> <command> [args...]
+#
+# This is here because OX X doesn't ship /usr/bin/timeout by default!?!?
+run_with_timeout() {
+    local timeout=$1
+    shift
+
+    # Run command in background and capture PID
+    "$@" &
+    local cmd_pid=$!
+
+    # Start timeout killer in background
+    (
+        sleep "$timeout"
+        if kill -0 "$cmd_pid" 2>/dev/null; then
+            echo "Command timed out after ${timeout}s, killing process" >&2
+            kill -TERM "$cmd_pid" 2>/dev/null || true
+            sleep 2
+            kill -KILL "$cmd_pid" 2>/dev/null || true
+        fi
+    ) &
+    local killer_pid=$!
+
+    # Wait for command to complete
+    local exit_code=0
+    if wait "$cmd_pid" 2>/dev/null; then
+        exit_code=$?
+    else
+        exit_code=124  # timeout exit code
+    fi
+
+    # Clean up timeout killer
+    kill "$killer_pid" 2>/dev/null || true
+    wait "$killer_pid" 2>/dev/null || true
+
+    return $exit_code
+}
+
+# Read stdin for ref updates
+while read oldrev newrev refname; do
+    # Check if this is a push to refs/remotes/origin/Y pattern
+    if [[ "$refname" =~ ^refs/remotes/origin/(.+)$ ]]; then
+        branch_name="${BASH_REMATCH[1]}"
+
+        # Check if this is a force push by seeing if oldrev is not ancestor of newrev
+        if [ "$oldrev" != "0000000000000000000000000000000000000000" ]; then
+            # Check if this is a fast-forward (oldrev is ancestor of newrev)
+            if ! git merge-base --is-ancestor "$oldrev" "$newrev" 2>/dev/null; then
+                echo "Error: Force push detected to refs/remotes/origin/$branch_name" >&2
+                echo "Force pushes are not allowed" >&2
+                exit 1
+            fi
+        fi
+
+        echo "Detected push to refs/remotes/origin/$branch_name" >&2
+
+        # Verify HTTP_USER_AGENT is set to sketch-intentional-push for forwarding
+        if [ "$HTTP_USER_AGENT" != "sketch-intentional-push" ]; then
+            echo "Error: Unauthorized push to refs/remotes/origin/$branch_name" >&2
+            exit 1
+        fi
+
+        echo "Authorization verified, forwarding to origin" >&2
+
+        # Push to origin using the new commit with 10 second timeout
+        # There's an innocous "ref updates forbidden inside quarantine environment" warning that we can ignore.
+        if ! run_with_timeout 10 git push origin "$newrev:refs/heads/$branch_name"; then
+            echo "Error: Failed to push $newrev to origin/$branch_name (may have timed out)" >&2
+            exit 1
+        fi
+
+        echo "Successfully pushed to origin/$branch_name" >&2
+    fi
+done
+
+exit 0
diff --git a/loop/agent.go b/loop/agent.go
index 21e10d7..1a13759 100644
--- a/loop/agent.go
+++ b/loop/agent.go
@@ -136,6 +136,9 @@
 	// GitUsername returns the git user name from the agent config.
 	GitUsername() string
 
+	// PassthroughUpstream returns whether passthrough upstream is enabled.
+	PassthroughUpstream() bool
+
 	// DiffStats returns the number of lines added and removed from sketch-base to HEAD
 	DiffStats() (int, int)
 	// OpenBrowser is a best-effort attempt to open a browser at url in outside sketch.
@@ -786,6 +789,11 @@
 	return a.config.OriginalGitOrigin
 }
 
+// PassthroughUpstream returns whether passthrough upstream is enabled.
+func (a *Agent) PassthroughUpstream() bool {
+	return a.config.PassthroughUpstream
+}
+
 // GitUsername returns the git user name from the agent config.
 func (a *Agent) GitUsername() string {
 	return a.config.GitUsername
@@ -1105,6 +1113,8 @@
 	MCPServers []string
 	// Timeout configuration for bash tool
 	BashTimeouts *claudetool.Timeouts
+	// PassthroughUpstream configures upstream remote for passthrough to innie
+	PassthroughUpstream bool
 }
 
 // NewAgent creates a new Agent.
@@ -1161,14 +1171,7 @@
 
 	// If a remote + commit was specified, clone it.
 	if a.config.Commit != "" && a.gitState.gitRemoteAddr != "" {
-		if _, err := os.Stat("/app/.git"); err == nil {
-			// Already a repo in /app.
-			// Make sure that the remote is configured correctly.
-			// We do a fetch below.
-			if err := upsertRemoteOrigin(ctx, "/app", a.gitState.gitRemoteAddr); err != nil {
-				return err
-			}
-		} else {
+		if _, err := os.Stat("/app/.git"); err != nil {
 			slog.InfoContext(ctx, "cloning git repo", "commit", a.config.Commit)
 			// TODO: --reference-if-able instead?
 			cmd := exec.CommandContext(ctx, "git", "clone", "--reference", "/git-ref", a.gitState.gitRemoteAddr, "/app")
@@ -1186,6 +1189,9 @@
 	}
 
 	if !ini.NoGit {
+		if err := upsertRemoteOrigin(ctx, "/app", a.gitState.gitRemoteAddr); err != nil {
+			return err
+		}
 
 		// Configure git user settings
 		if a.config.GitEmail != "" {
@@ -1208,6 +1214,13 @@
 		if out, err := cmd.CombinedOutput(); err != nil {
 			return fmt.Errorf("git config --global http.postBuffer: %s: %v", out, err)
 		}
+
+		// Configure passthrough upstream if enabled
+		if a.config.PassthroughUpstream {
+			if err := a.configurePassthroughUpstream(ctx); err != nil {
+				return fmt.Errorf("failed to configure passthrough upstream: %w", err)
+			}
+		}
 	}
 
 	// If a commit was specified, we fetch and reset to it.
@@ -2306,6 +2319,8 @@
 
 // upsertRemoteOrigin configures the origin remote to point to the given URL.
 // If the origin remote exists, it updates the URL. If it doesn't exist, it adds it.
+//
+// NOTE: Maybe we should use an "insteadOf" setting instead of changing the URL.
 func upsertRemoteOrigin(ctx context.Context, repoDir, remoteURL string) error {
 	// Try to set the URL for existing origin remote
 	cmd := exec.CommandContext(ctx, "git", "remote", "set-url", "origin", remoteURL)
@@ -2635,6 +2650,63 @@
 	return nil
 }
 
+// configurePassthroughUpstream configures git remotes
+// Adds an upstream remote pointing to the same as origin
+// Sets the refspec for upstream and fetch such that both
+// fetch the upstream's things into refs/remotes/upstream/foo
+// The typical scenario is:
+//
+//	github     -   laptop   -  sketch container
+//	"upstream"    "origin"
+func (a *Agent) configurePassthroughUpstream(ctx context.Context) error {
+	// Get the origin remote URL
+	cmd := exec.CommandContext(ctx, "git", "remote", "get-url", "origin")
+	cmd.Dir = a.workingDir
+	originURLBytes, err := cmd.CombinedOutput()
+	if err != nil {
+		return fmt.Errorf("failed to get origin URL: %s: %w", originURLBytes, err)
+	}
+	originURL := strings.TrimSpace(string(originURLBytes))
+
+	// Check if upstream remote already exists
+	cmd = exec.CommandContext(ctx, "git", "remote", "get-url", "upstream")
+	cmd.Dir = a.workingDir
+	if _, err := cmd.CombinedOutput(); err != nil {
+		// upstream remote doesn't exist, create it
+		cmd = exec.CommandContext(ctx, "git", "remote", "add", "upstream", originURL)
+		cmd.Dir = a.workingDir
+		if out, err := cmd.CombinedOutput(); err != nil {
+			return fmt.Errorf("failed to add upstream remote: %s: %w", out, err)
+		}
+		slog.InfoContext(ctx, "added upstream remote", "url", originURL)
+	} else {
+		// upstream remote exists, update its URL
+		cmd = exec.CommandContext(ctx, "git", "remote", "set-url", "upstream", originURL)
+		cmd.Dir = a.workingDir
+		if out, err := cmd.CombinedOutput(); err != nil {
+			return fmt.Errorf("failed to set upstream remote URL: %s: %w", out, err)
+		}
+		slog.InfoContext(ctx, "updated upstream remote URL", "url", originURL)
+	}
+
+	// Add the upstream refspec to the upstream remote
+	cmd = exec.CommandContext(ctx, "git", "config", "remote.upstream.fetch", "+refs/remotes/origin/*:refs/remotes/upstream/*")
+	cmd.Dir = a.workingDir
+	if out, err := cmd.CombinedOutput(); err != nil {
+		return fmt.Errorf("failed to set upstream fetch refspec: %s: %w", out, err)
+	}
+
+	// Add the same refspec to the origin remote
+	cmd = exec.CommandContext(ctx, "git", "config", "--add", "remote.origin.fetch", "+refs/remotes/origin/*:refs/remotes/upstream/*")
+	cmd.Dir = a.workingDir
+	if out, err := cmd.CombinedOutput(); err != nil {
+		return fmt.Errorf("failed to add upstream refspec to origin: %s: %w", out, err)
+	}
+
+	slog.InfoContext(ctx, "configured passthrough upstream", "origin_url", originURL)
+	return nil
+}
+
 // SkabandAddr returns the skaband address if configured
 func (a *Agent) SkabandAddr() string {
 	if a.config.SkabandClient != nil {
diff --git a/loop/server/loophttp.go b/loop/server/loophttp.go
index 79e9e54..296317b 100644
--- a/loop/server/loophttp.go
+++ b/loop/server/loophttp.go
@@ -19,6 +19,7 @@
 	"os"
 	"os/exec"
 	"path/filepath"
+	"regexp"
 	"runtime/debug"
 	"strconv"
 	"strings"
@@ -34,6 +35,60 @@
 	"sketch.dev/loop/server/gzhandler"
 )
 
+// Remote represents a git remote with display information.
+type Remote struct {
+	Name        string `json:"name"`
+	URL         string `json:"url"`
+	DisplayName string `json:"display_name"`
+	IsGitHub    bool   `json:"is_github"`
+}
+
+// GitPushInfoResponse represents the response from /git/pushinfo
+type GitPushInfoResponse struct {
+	Hash    string   `json:"hash"`
+	Subject string   `json:"subject"`
+	Remotes []Remote `json:"remotes"`
+}
+
+// GitPushRequest represents the request body for /git/push
+type GitPushRequest struct {
+	Remote string `json:"remote"`
+	Branch string `json:"branch"`
+	Commit string `json:"commit"`
+	DryRun bool   `json:"dry_run"`
+	Force  bool   `json:"force"`
+}
+
+// GitPushResponse represents the response from /git/push
+type GitPushResponse struct {
+	Success bool   `json:"success"`
+	Output  string `json:"output"`
+	DryRun  bool   `json:"dry_run"`
+	Error   string `json:"error,omitempty"`
+}
+
+// isGitHubURL checks if a URL is a GitHub URL
+func isGitHubURL(url string) bool {
+	return strings.Contains(url, "github.com")
+}
+
+// simplifyGitHubURL simplifies GitHub URLs to "owner/repo" format
+// and also returns whether it's a github url
+func simplifyGitHubURL(url string) (string, bool) {
+	// Handle GitHub URLs in various formats
+	if strings.Contains(url, "github.com") {
+		// Extract owner/repo from URLs like:
+		// https://github.com/owner/repo.git
+		// git@github.com:owner/repo.git
+		// https://github.com/owner/repo
+		re := regexp.MustCompile(`github\.com[:/]([^/]+/[^/]+?)(?:\.git)?/?$`)
+		if matches := re.FindStringSubmatch(url); len(matches) > 1 {
+			return matches[1], true
+		}
+	}
+	return url, false
+}
+
 // terminalSession represents a terminal session with its PTY and the event channel
 type terminalSession struct {
 	pty                *os.File
@@ -691,6 +746,12 @@
 		json.NewEncoder(w).Encode(map[string]string{"path": filename})
 	})
 
+	// Handler for /git/pushinfo - returns HEAD commit and remotes for push dialog
+	s.mux.HandleFunc("/git/pushinfo", s.handleGitPushInfo)
+
+	// Handler for /git/push - handles git push operations
+	s.mux.HandleFunc("/git/push", s.handleGitPush)
+
 	// Handler for /cancel - cancels the current inner loop in progress
 	s.mux.HandleFunc("/cancel", func(w http.ResponseWriter, r *http.Request) {
 		if r.Method != http.MethodPost {
@@ -1560,3 +1621,178 @@
 	}
 	_ = json.NewEncoder(w).Encode(response) // can't do anything useful with errors anyway
 }
+
+// handleGitPushInfo returns the current HEAD commit info and remotes for push dialog
+func (s *Server) handleGitPushInfo(w http.ResponseWriter, r *http.Request) {
+	if r.Method != "GET" {
+		w.WriteHeader(http.StatusMethodNotAllowed)
+		return
+	}
+
+	repoDir := s.agent.RepoRoot()
+
+	// Get the current HEAD commit hash and subject in one command
+	cmd := exec.Command("git", "log", "-n", "1", "--format=%H%x00%s", "HEAD")
+	cmd.Dir = repoDir
+	output, err := cmd.Output()
+	if err != nil {
+		http.Error(w, fmt.Sprintf("Error getting HEAD commit: %v", err), http.StatusInternalServerError)
+		return
+	}
+
+	parts := strings.Split(strings.TrimSpace(string(output)), "\x00")
+	if len(parts) != 2 {
+		http.Error(w, "Unexpected git log output format", http.StatusInternalServerError)
+		return
+	}
+	hash := parts[0]
+	subject := parts[1]
+
+	// Get list of remote names
+	cmd = exec.Command("git", "remote")
+	cmd.Dir = repoDir
+	output, err = cmd.Output()
+	if err != nil {
+		http.Error(w, fmt.Sprintf("Error getting remotes: %v", err), http.StatusInternalServerError)
+		return
+	}
+
+	remoteNames := strings.Fields(strings.TrimSpace(string(output)))
+
+	remotes := make([]Remote, 0, len(remoteNames))
+
+	// Get URL and display name for each remote
+	for _, remoteName := range remoteNames {
+		cmd = exec.Command("git", "remote", "get-url", remoteName)
+		cmd.Dir = repoDir
+		urlOutput, err := cmd.Output()
+		if err != nil {
+			// Skip this remote if we can't get its URL
+			continue
+		}
+		url := strings.TrimSpace(string(urlOutput))
+
+		// Set display name based on passthrough-upstream and remote name
+		var displayName string
+		var isGitHub bool
+		if s.agent.PassthroughUpstream() && remoteName == "origin" {
+			// For passthrough upstream, origin displays as "outside_hostname:outside_working_dir"
+			displayName = fmt.Sprintf("%s:%s", s.agent.OutsideHostname(), s.agent.OutsideWorkingDir())
+			isGitHub = false
+		} else if remoteName == "origin" || remoteName == "upstream" {
+			// Use git_origin value, simplified for GitHub URLs
+			displayName, isGitHub = simplifyGitHubURL(s.agent.GitOrigin())
+		} else {
+			// For other remotes, use the remote URL directly
+			displayName, isGitHub = simplifyGitHubURL(url)
+		}
+
+		remotes = append(remotes, Remote{
+			Name:        remoteName,
+			URL:         url,
+			DisplayName: displayName,
+			IsGitHub:    isGitHub,
+		})
+	}
+
+	w.Header().Set("Content-Type", "application/json")
+	response := GitPushInfoResponse{
+		Hash:    hash,
+		Subject: subject,
+		Remotes: remotes,
+	}
+	_ = json.NewEncoder(w).Encode(response)
+}
+
+// handleGitPush handles git push operations
+func (s *Server) handleGitPush(w http.ResponseWriter, r *http.Request) {
+	if r.Method != "POST" {
+		w.WriteHeader(http.StatusMethodNotAllowed)
+		return
+	}
+
+	// Parse request body
+	var requestBody GitPushRequest
+
+	if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
+		http.Error(w, fmt.Sprintf("Error parsing request body: %v", err), http.StatusBadRequest)
+		return
+	}
+	defer r.Body.Close()
+
+	if requestBody.Remote == "" || requestBody.Branch == "" || requestBody.Commit == "" {
+		http.Error(w, "Missing required parameters: remote, branch, and commit", http.StatusBadRequest)
+		return
+	}
+
+	repoDir := s.agent.RepoRoot()
+
+	// Build the git push command
+	args := []string{"push"}
+	if requestBody.DryRun {
+		args = append(args, "--dry-run")
+	}
+	if requestBody.Force {
+		args = append(args, "--force")
+	}
+
+	// Determine the target refspec
+	var targetRef string
+	if s.agent.PassthroughUpstream() && requestBody.Remote == "upstream" {
+		// Special case: upstream with passthrough-upstream pushes to refs/remotes/origin/<branch>
+		targetRef = fmt.Sprintf("refs/remotes/origin/%s", requestBody.Branch)
+	} else {
+		// Normal case: push to refs/heads/<branch>
+		targetRef = fmt.Sprintf("refs/heads/%s", requestBody.Branch)
+	}
+
+	args = append(args, requestBody.Remote, fmt.Sprintf("%s:%s", requestBody.Commit, targetRef))
+
+	// Log the git push command being executed
+	slog.InfoContext(r.Context(), "executing git push command",
+		"command", "git",
+		"args", args,
+		"remote", requestBody.Remote,
+		"branch", requestBody.Branch,
+		"commit", requestBody.Commit,
+		"target_ref", targetRef,
+		"dry_run", requestBody.DryRun,
+		"force", requestBody.Force,
+		"repo_dir", repoDir)
+
+	cmd := exec.Command("git", args...)
+	cmd.Dir = repoDir
+	// Ideally we want to pass an extra HTTP header so that the
+	// server can know that this was likely a user initiated action
+	// and not an agent-initiated action. However, git push weirdly
+	// doesn't take a "-c" option, and the only handy env variable that
+	// because a header is the user agent, so we abuse it...
+	cmd.Env = append(os.Environ(), "GIT_HTTP_USER_AGENT=sketch-intentional-push")
+	output, err := cmd.CombinedOutput()
+
+	// Log the result of the git push command
+	if err != nil {
+		slog.WarnContext(r.Context(), "git push command failed",
+			"error", err,
+			"output", string(output),
+			"args", args)
+	} else {
+		slog.InfoContext(r.Context(), "git push command completed successfully",
+			"output", string(output),
+			"args", args)
+	}
+
+	// Prepare response
+	response := GitPushResponse{
+		Success: err == nil,
+		Output:  string(output),
+		DryRun:  requestBody.DryRun,
+	}
+
+	if err != nil {
+		response.Error = err.Error()
+	}
+
+	w.Header().Set("Content-Type", "application/json")
+	_ = json.NewEncoder(w).Encode(response)
+}
diff --git a/loop/server/loophttp_test.go b/loop/server/loophttp_test.go
index c6d4b6b..04a18ba 100644
--- a/loop/server/loophttp_test.go
+++ b/loop/server/loophttp_test.go
@@ -3,6 +3,7 @@
 import (
 	"bufio"
 	"context"
+	"io"
 	"net/http"
 	"net/http/httptest"
 	"slices"
@@ -245,6 +246,7 @@
 func (m *mockAgent) OutsideWorkingDir() string                   { return "/app" }
 func (m *mockAgent) GitOrigin() string                           { return "" }
 func (m *mockAgent) GitUsername() string                         { return m.gitUsername }
+func (m *mockAgent) PassthroughUpstream() bool                   { return false }
 func (m *mockAgent) OpenBrowser(url string)                      {}
 func (m *mockAgent) CompactConversation(ctx context.Context) error {
 	// Mock implementation - just return nil
@@ -678,3 +680,130 @@
 
 	t.Log("State endpoint includes port information correctly")
 }
+
+// TestGitPushHandler tests the git push endpoint
+func TestGitPushHandler(t *testing.T) {
+	mockAgent := &mockAgent{
+		workingDir:   t.TempDir(),
+		branchPrefix: "sketch/",
+	}
+
+	// Create the server with the mock agent
+	server, err := server.New(mockAgent, nil)
+	if err != nil {
+		t.Fatalf("Failed to create server: %v", err)
+	}
+
+	// Create a test HTTP server
+	testServer := httptest.NewServer(server)
+	defer testServer.Close()
+
+	// Test missing required parameters
+	tests := []struct {
+		name           string
+		requestBody    string
+		expectedStatus int
+		expectedError  string
+	}{
+		{
+			name:           "missing all parameters",
+			requestBody:    `{}`,
+			expectedStatus: http.StatusBadRequest,
+			expectedError:  "Missing required parameters: remote, branch, and commit",
+		},
+		{
+			name:           "missing commit parameter",
+			requestBody:    `{"remote": "origin", "branch": "main"}`,
+			expectedStatus: http.StatusBadRequest,
+			expectedError:  "Missing required parameters: remote, branch, and commit",
+		},
+		{
+			name:           "missing remote parameter",
+			requestBody:    `{"branch": "main", "commit": "abc123"}`,
+			expectedStatus: http.StatusBadRequest,
+			expectedError:  "Missing required parameters: remote, branch, and commit",
+		},
+		{
+			name:           "missing branch parameter",
+			requestBody:    `{"remote": "origin", "commit": "abc123"}`,
+			expectedStatus: http.StatusBadRequest,
+			expectedError:  "Missing required parameters: remote, branch, and commit",
+		},
+		{
+			name:           "all parameters present",
+			requestBody:    `{"remote": "origin", "branch": "main", "commit": "abc123", "dry_run": true}`,
+			expectedStatus: http.StatusOK, // Parameters are valid, response will be JSON
+			expectedError:  "",            // No parameter validation error
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			resp, err := http.Post(
+				testServer.URL+"/git/push",
+				"application/json",
+				strings.NewReader(tt.requestBody),
+			)
+			if err != nil {
+				t.Fatalf("Failed to make HTTP request: %v", err)
+			}
+			defer resp.Body.Close()
+
+			if resp.StatusCode != tt.expectedStatus {
+				t.Errorf("Expected status %d, got: %d", tt.expectedStatus, resp.StatusCode)
+			}
+
+			if tt.expectedError != "" {
+				body, err := io.ReadAll(resp.Body)
+				if err != nil {
+					t.Fatalf("Failed to read response body: %v", err)
+				}
+				if !strings.Contains(string(body), tt.expectedError) {
+					t.Errorf("Expected error message '%s', got: %s", tt.expectedError, string(body))
+				}
+			}
+		})
+	}
+}
+
+// TestGitPushInfoHandler tests the git push info endpoint
+func TestGitPushInfoHandler(t *testing.T) {
+	mockAgent := &mockAgent{
+		workingDir:   t.TempDir(),
+		branchPrefix: "sketch/",
+	}
+
+	// Create the server with the mock agent
+	server, err := server.New(mockAgent, nil)
+	if err != nil {
+		t.Fatalf("Failed to create server: %v", err)
+	}
+
+	// Create a test HTTP server
+	testServer := httptest.NewServer(server)
+	defer testServer.Close()
+
+	// Test GET request
+	resp, err := http.Get(testServer.URL + "/git/pushinfo")
+	if err != nil {
+		t.Fatalf("Failed to make HTTP request: %v", err)
+	}
+	defer resp.Body.Close()
+
+	// We expect this to fail with 500 since there's no git repository
+	// but the endpoint should be accessible
+	if resp.StatusCode != http.StatusInternalServerError {
+		t.Errorf("Expected status 500, got: %d", resp.StatusCode)
+	}
+
+	// Test that POST is not allowed
+	resp, err = http.Post(testServer.URL+"/git/pushinfo", "application/json", strings.NewReader(`{}`))
+	if err != nil {
+		t.Fatalf("Failed to make HTTP request: %v", err)
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusMethodNotAllowed {
+		t.Errorf("Expected status 405, got: %d", resp.StatusCode)
+	}
+}
diff --git a/webui/src/types.ts b/webui/src/types.ts
index 967a1d7..6cc4150 100644
--- a/webui/src/types.ts
+++ b/webui/src/types.ts
@@ -115,6 +115,34 @@
 	items: TodoItem[] | null;
 }
 
+export interface Remote {
+	name: string;
+	url: string;
+	display_name: string;
+	is_github: boolean;
+}
+
+export interface GitPushInfoResponse {
+	hash: string;
+	subject: string;
+	remotes: Remote[] | null;
+}
+
+export interface GitPushRequest {
+	remote: string;
+	branch: string;
+	commit: string;
+	dry_run: boolean;
+	force: boolean;
+}
+
+export interface GitPushResponse {
+	success: boolean;
+	output: string;
+	dry_run: boolean;
+	error?: string;
+}
+
 export interface MultipleChoiceOption {
 	caption: string;
 	responseText: string;
diff --git a/webui/src/web-components/demo/mock-git-data-service.ts b/webui/src/web-components/demo/mock-git-data-service.ts
index 70bf26b..92fae9e 100644
--- a/webui/src/web-components/demo/mock-git-data-service.ts
+++ b/webui/src/web-components/demo/mock-git-data-service.ts
@@ -10,6 +10,8 @@
 export class MockGitDataService implements GitDataService {
   constructor() {
     console.log("MockGitDataService instance created");
+    // Setup mock push endpoints when service is created
+    setupMockPushEndpoints();
   }
 
   // Mock commit history
@@ -573,3 +575,70 @@
     ];
   }
 }
+
+// Mock HTTP endpoints for push demo
+export function setupMockPushEndpoints() {
+  // Mock the git/pushinfo endpoint
+  const originalFetch = window.fetch;
+
+  window.fetch = async (url: RequestInfo | URL, init?: RequestInit) => {
+    const urlString = typeof url === "string" ? url : url.toString();
+
+    // Mock pushinfo endpoint
+    if (urlString.includes("/git/pushinfo")) {
+      await new Promise((resolve) => setTimeout(resolve, 500)); // Simulate network delay
+
+      return new Response(
+        JSON.stringify({
+          hash: "abc123456789",
+          subject: "Implement new file picker UI",
+          remotes: [
+            {
+              name: "origin",
+              url: "https://github.com/boldsoftware/bold.git",
+              display_name: "boldsoftware/bold",
+              is_github: true,
+            },
+            {
+              name: "upstream",
+              url: "https://github.com/anotheruser/bold.git",
+              display_name: "anotheruser/bold",
+              is_github: true,
+            },
+          ],
+        }),
+        {
+          status: 200,
+          headers: { "Content-Type": "application/json" },
+        },
+      );
+    }
+
+    // Mock push endpoint
+    if (urlString.includes("/git/push")) {
+      await new Promise((resolve) => setTimeout(resolve, 1500)); // Simulate push delay
+
+      const body = init?.body ? JSON.parse(init.body as string) : {};
+      const isDryRun = body.dry_run || false;
+
+      const mockOutput = isDryRun
+        ? `To https://github.com/boldsoftware/bold.git\n   abc1234..def5678  ${body.branch || "main"} -> ${body.branch || "main"} (dry-run)`
+        : `To https://github.com/boldsoftware/bold.git\n   abc1234..def5678  ${body.branch || "main"} -> ${body.branch || "main"}\n\nCreate a pull request for '${body.branch || "main"}' on GitHub by visiting:\n  https://github.com/boldsoftware/bold/pull/new/${body.branch || "main"}`;
+
+      return new Response(
+        JSON.stringify({
+          success: true,
+          output: mockOutput,
+          dry_run: isDryRun,
+        }),
+        {
+          status: 200,
+          headers: { "Content-Type": "application/json" },
+        },
+      );
+    }
+
+    // Fall back to original fetch for other requests
+    return originalFetch(url, init);
+  };
+}
diff --git a/webui/src/web-components/demo/sketch-push-button.demo.html b/webui/src/web-components/demo/sketch-push-button.demo.html
new file mode 100644
index 0000000..41f750b
--- /dev/null
+++ b/webui/src/web-components/demo/sketch-push-button.demo.html
@@ -0,0 +1,31 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>Push Button Demo</title>
+    <script src="https://cdn.tailwindcss.com"></script>
+    <style>
+      body {
+        font-family:
+          -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+        margin: 0;
+        padding: 20px;
+        background-color: #f9fafb;
+      }
+    </style>
+  </head>
+  <body>
+    <div class="max-w-4xl mx-auto">
+      <h1 class="text-2xl font-bold mb-8 text-gray-800">
+        Push Button Component Demo
+      </h1>
+
+      <div class="bg-white rounded-lg shadow-lg p-6">
+        <sketch-push-button-demo></sketch-push-button-demo>
+      </div>
+    </div>
+
+    <script type="module" src="./sketch-push-button.demo.ts"></script>
+  </body>
+</html>
diff --git a/webui/src/web-components/demo/sketch-push-button.demo.ts b/webui/src/web-components/demo/sketch-push-button.demo.ts
new file mode 100644
index 0000000..9b54c01
--- /dev/null
+++ b/webui/src/web-components/demo/sketch-push-button.demo.ts
@@ -0,0 +1,47 @@
+import { html, LitElement } from "lit";
+import { customElement, state } from "lit/decorators.js";
+import { MockGitDataService } from "./mock-git-data-service.js";
+import "../sketch-push-button.js";
+
+@customElement("sketch-push-button-demo")
+export class SketchPushButtonDemo extends LitElement {
+  @state()
+  private _gitDataService = new MockGitDataService();
+
+  protected createRenderRoot() {
+    return this;
+  }
+
+  render() {
+    return html`
+      <div
+        class="p-4 bg-white rounded-lg shadow-sm border border-gray-200 max-w-md mx-auto"
+      >
+        <h2 class="text-lg font-semibold mb-4">Push Button Demo</h2>
+
+        <div class="mb-4">
+          <p class="text-sm text-gray-600 mb-2">
+            Test the push button component:
+          </p>
+          <sketch-push-button></sketch-push-button>
+        </div>
+
+        <div class="text-xs text-gray-500">
+          <p>Click the push button to test:</p>
+          <ul class="list-disc list-inside mt-1">
+            <li>Modal opens with git information</li>
+            <li>Input fields can be disabled during loading</li>
+            <li>Buttons show individual spinners</li>
+            <li>No full modal overwrite during operations</li>
+          </ul>
+        </div>
+      </div>
+    `;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    "sketch-push-button-demo": SketchPushButtonDemo;
+  }
+}
diff --git a/webui/src/web-components/sketch-app-shell-base.ts b/webui/src/web-components/sketch-app-shell-base.ts
index 3e3dbe3..3ddc183 100644
--- a/webui/src/web-components/sketch-app-shell-base.ts
+++ b/webui/src/web-components/sketch-app-shell-base.ts
@@ -15,6 +15,7 @@
 import "./sketch-monaco-view";
 import "./sketch-network-status";
 import "./sketch-call-status";
+import "./sketch-push-button";
 import "./sketch-terminal";
 import "./sketch-timeline";
 import "./sketch-view-mode-select";
@@ -156,6 +157,7 @@
     this._handleShowCommitDiff = this._handleShowCommitDiff.bind(this);
     this._handleMutlipleChoiceSelected =
       this._handleMutlipleChoiceSelected.bind(this);
+    this._handlePushRebaseRequest = this._handlePushRebaseRequest.bind(this);
     this._handleStopClick = this._handleStopClick.bind(this);
     this._handleEndClick = this._handleEndClick.bind(this);
     this._handleNotificationsToggle =
@@ -203,6 +205,10 @@
       "multiple-choice-selected",
       this._handleMutlipleChoiceSelected,
     );
+    window.addEventListener(
+      "push-rebase-request",
+      this._handlePushRebaseRequest,
+    );
 
     // register event listeners
     this.dataManager.addEventListener(
@@ -251,6 +257,10 @@
       "multiple-choice-selected",
       this._handleMutlipleChoiceSelected,
     );
+    window.removeEventListener(
+      "push-rebase-request",
+      this._handlePushRebaseRequest,
+    );
 
     // unregister data manager event listeners
     this.dataManager.removeEventListener(
@@ -770,6 +780,25 @@
     }
   }
 
+  async _handlePushRebaseRequest(e: CustomEvent) {
+    const chatInput = this.querySelector(
+      "sketch-chat-input",
+    ) as SketchChatInput;
+    if (chatInput) {
+      if (chatInput.content && chatInput.content.trim() !== "") {
+        chatInput.content += "\n\n";
+      }
+      chatInput.content += e.detail.message;
+      chatInput.focus();
+      // Adjust textarea height to accommodate new content
+      requestAnimationFrame(() => {
+        if (chatInput.adjustChatSpacing) {
+          chatInput.adjustChatSpacing();
+        }
+      });
+    }
+  }
+
   async _sendChat(e: CustomEvent) {
     console.log("app shell: _sendChat", e);
     e.preventDefault();
diff --git a/webui/src/web-components/sketch-container-status.ts b/webui/src/web-components/sketch-container-status.ts
index 2bd9935..bd9524b 100644
--- a/webui/src/web-components/sketch-container-status.ts
+++ b/webui/src/web-components/sketch-container-status.ts
@@ -3,6 +3,7 @@
 import { customElement, property, state } from "lit/decorators.js";
 import { formatNumber } from "../utils";
 import { SketchTailwindElement } from "./sketch-tailwind-element";
+import "./sketch-push-button";
 
 @customElement("sketch-container-status")
 export class SketchContainerStatus extends SketchTailwindElement {
@@ -645,6 +646,9 @@
           `;
         })()}
 
+        <!-- Push button -->
+        <sketch-push-button class="ml-2"></sketch-push-button>
+
         <!-- Info toggle button -->
         <button
           class="info-toggle ml-2 w-6 h-6 rounded-full flex items-center justify-center ${this
@@ -665,6 +669,7 @@
           class="${this.showDetails
             ? "block"
             : "hidden"} absolute min-w-max top-full z-100 bg-white rounded-lg p-4 shadow-lg mt-1.5"
+          style="left: 50%; transform: translateX(-50%);"
         >
           <!-- Last Commit section moved to main grid -->
 
diff --git a/webui/src/web-components/sketch-push-button.ts b/webui/src/web-components/sketch-push-button.ts
new file mode 100644
index 0000000..b43ca4b
--- /dev/null
+++ b/webui/src/web-components/sketch-push-button.ts
@@ -0,0 +1,537 @@
+import { html } from "lit";
+import { customElement, state } from "lit/decorators.js";
+import { SketchTailwindElement } from "./sketch-tailwind-element.js";
+import type { Remote } from "../types.js";
+
+@customElement("sketch-push-button")
+export class SketchPushButton extends SketchTailwindElement {
+  @state()
+  private _modalOpen = false;
+
+  @state()
+  private _loading = false;
+
+  @state()
+  private _pushingAction: "dry-run" | "push" | null = null;
+
+  @state()
+  private _headCommit: { hash: string; subject: string } | null = null;
+
+  @state()
+  private _remotes: Remote[] = [];
+
+  @state()
+  private _selectedRemote = "";
+
+  @state()
+  private _branch = "";
+
+  @state()
+  private _pushResult: {
+    success: boolean;
+    output: string;
+    error?: string;
+    dry_run: boolean;
+  } | null = null;
+
+  private async _openModal() {
+    this._modalOpen = true;
+    this._loading = true;
+    this._pushResult = null;
+
+    try {
+      // Fetch push info (HEAD commit and remotes)
+      const response = await fetch("./git/pushinfo");
+      if (response.ok) {
+        const data = await response.json();
+        this._headCommit = {
+          hash: data.hash,
+          subject: data.subject,
+        };
+        this._remotes = data.remotes;
+
+        // Auto-select first remote if available
+        if (this._remotes.length > 0) {
+          this._selectedRemote = this._remotes[0].name;
+        }
+      }
+    } catch (error) {
+      console.error("Error fetching git data:", error);
+    } finally {
+      this._loading = false;
+    }
+  }
+
+  private _closeModal() {
+    this._modalOpen = false;
+    this._pushResult = null;
+  }
+
+  private _clickOutsideHandler = (event: MouseEvent) => {
+    if (this._modalOpen && !this.contains(event.target as Node)) {
+      this._closeModal();
+    }
+  };
+
+  // Close the modal when clicking outside
+  connectedCallback() {
+    super.connectedCallback();
+    document.addEventListener("click", this._clickOutsideHandler);
+  }
+
+  disconnectedCallback() {
+    super.disconnectedCallback();
+    document.removeEventListener("click", this._clickOutsideHandler);
+  }
+
+  private async _handlePush(dryRun: boolean = false, event?: Event) {
+    if (event) {
+      event.stopPropagation();
+    }
+
+    if (!this._selectedRemote || !this._branch || !this._headCommit) {
+      return;
+    }
+
+    this._loading = true;
+    this._pushingAction = dryRun ? "dry-run" : "push";
+
+    try {
+      const response = await fetch("./git/push", {
+        method: "POST",
+        headers: {
+          "Content-Type": "application/json",
+        },
+        body: JSON.stringify({
+          remote: this._selectedRemote,
+          branch: this._branch,
+          commit: this._headCommit.hash,
+          dry_run: dryRun,
+        }),
+      });
+
+      if (response.ok) {
+        this._pushResult = await response.json();
+      } else {
+        this._pushResult = {
+          success: false,
+          output: "",
+          error: `HTTP ${response.status}: ${response.statusText}`,
+          dry_run: dryRun,
+        };
+      }
+    } catch (error) {
+      this._pushResult = {
+        success: false,
+        output: "",
+        error: `Network error: ${error}`,
+        dry_run: dryRun,
+      };
+    } finally {
+      this._loading = false;
+      this._pushingAction = null;
+    }
+  }
+
+  private _handleRebase(event?: Event) {
+    if (event) {
+      event.stopPropagation();
+    }
+
+    // Send message to chat asking agent to rebase
+    const message = `fetch and rebase onto ${this._selectedRemote}/${this._branch}; force tag ${this._selectedRemote}/${this._branch} as the new sketch-base`;
+
+    // Dispatch custom event to send message to chat
+    const chatEvent = new CustomEvent("push-rebase-request", {
+      detail: { message },
+      bubbles: true,
+      composed: true,
+    });
+
+    window.dispatchEvent(chatEvent);
+  }
+
+  private _formatRemoteDisplay(remote: Remote): string {
+    return `${remote.display_name} (${remote.name})`;
+  }
+
+  private _renderRemoteDisplay(remote: Remote) {
+    const displayText = this._formatRemoteDisplay(remote);
+    if (remote.is_github) {
+      const githubURL = `https://github.com/${remote.display_name}`;
+      if (githubURL) {
+        return html`<a
+          href="${githubURL}"
+          target="_blank"
+          class="text-blue-600 hover:text-blue-800 underline"
+          >${displayText}</a
+        >`;
+      }
+    }
+    return html`<span>${displayText}</span>`;
+  }
+
+  private _makeLinksClickable(output: string): string {
+    // Regex to match http:// or https:// URLs
+    return output.replace(/(https?:\/\/[^\s]+)/g, (match) => {
+      // Clean up URL (remove trailing punctuation)
+      const cleanURL = match.replace(/[.,!?;]+$/, "");
+      const trailingPunctuation = match.substring(cleanURL.length);
+      return `<a href="${cleanURL}" target="_blank" class="text-blue-600 hover:text-blue-800 underline">${cleanURL}</a>${trailingPunctuation}`;
+    });
+  }
+
+  private _getSelectedRemote(): Remote | null {
+    return this._remotes.find((r) => r.name === this._selectedRemote) || null;
+  }
+
+  private _computeBranchURL(): string {
+    const selectedRemote = this._getSelectedRemote();
+    if (!selectedRemote) {
+      return "";
+    }
+    return `https://github.com/${selectedRemote?.display_name}/tree/${this._branch}`;
+  }
+
+  private _renderRemoteSelection() {
+    if (this._remotes.length === 0) {
+      return html``;
+    }
+
+    if (this._remotes.length === 1) {
+      // Single remote - just show it, no selection needed
+      const remote = this._remotes[0];
+      if (!this._selectedRemote) {
+        this._selectedRemote = remote.name;
+      }
+      return html`
+        <div class="mb-3">
+          <label class="block text-xs font-medium mb-1">Remote:</label>
+          <div class="p-2 bg-gray-50 rounded text-xs text-gray-700">
+            ${this._renderRemoteDisplay(remote)}
+          </div>
+        </div>
+      `;
+    }
+
+    if (this._remotes.length === 2) {
+      // Two remotes - use radio buttons
+      return html`
+        <div class="mb-3">
+          <label class="block text-xs font-medium mb-1">Remote:</label>
+          <div class="space-y-2">
+            ${this._remotes.map(
+              (remote) => html`
+                <label class="flex items-center space-x-2 cursor-pointer">
+                  <input
+                    type="radio"
+                    name="remote"
+                    .value=${remote.name}
+                    .checked=${remote.name === this._selectedRemote}
+                    ?disabled=${this._loading}
+                    @change=${(e: Event) => {
+                      this._selectedRemote = (
+                        e.target as HTMLInputElement
+                      ).value;
+                    }}
+                    class="text-blue-600 focus:ring-blue-500"
+                  />
+                  <span class="text-xs text-gray-700"
+                    >${this._renderRemoteDisplay(remote)}</span
+                  >
+                </label>
+              `,
+            )}
+          </div>
+        </div>
+      `;
+    }
+
+    // Three or more remotes - use dropdown
+    return html`
+      <div class="mb-3">
+        <label class="block text-xs font-medium mb-1">Remote:</label>
+        <select
+          .value=${this._selectedRemote}
+          ?disabled=${this._loading}
+          @change=${(e: Event) => {
+            this._selectedRemote = (e.target as HTMLSelectElement).value;
+          }}
+          class="w-full p-2 border border-gray-300 rounded text-xs focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
+        >
+          <option value="">Select a remote...</option>
+          ${this._remotes.map(
+            (remote) => html`
+              <option
+                value="${remote.name}"
+                ?selected=${remote.name === this._selectedRemote}
+              >
+                ${this._formatRemoteDisplay(remote)}
+              </option>
+            `,
+          )}
+        </select>
+      </div>
+    `;
+  }
+
+  render() {
+    return html`
+      <div class="relative">
+        <!-- Push Button -->
+        <button
+          @click=${this._openModal}
+          class="flex items-center gap-1.5 px-2 py-1 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors"
+          title="Push current HEAD to remote"
+        >
+          <svg
+            class="w-4 h-4"
+            viewBox="0 0 24 24"
+            fill="none"
+            stroke="currentColor"
+            stroke-width="2"
+          >
+            <path d="M12 19V5M5 12l7-7 7 7" />
+          </svg>
+          <span class="max-sm:hidden">Push</span>
+        </button>
+
+        <!-- Overlay Popup -->
+        <div
+          class="${this._modalOpen
+            ? "block"
+            : "hidden"} absolute top-full z-50 bg-white rounded-lg p-4 shadow-lg mt-1.5 border border-gray-200"
+          style="width: 420px; left: 50%; transform: translateX(-50%);"
+        >
+          <div class="flex justify-between items-center mb-3">
+            <h3 class="text-sm font-semibold">Push to Remote</h3>
+            <button
+              @click=${this._closeModal}
+              class="text-gray-500 hover:text-gray-700 transition-colors"
+            >
+              <svg
+                class="w-4 h-4"
+                viewBox="0 0 24 24"
+                fill="none"
+                stroke="currentColor"
+                stroke-width="2"
+              >
+                <path d="M18 6L6 18M6 6l12 12" />
+              </svg>
+            </button>
+          </div>
+
+          ${this._loading && !this._headCommit
+            ? html`
+                <div class="text-center py-4">
+                  <div
+                    class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mx-auto"
+                  ></div>
+                  <p class="mt-2 text-gray-600 text-xs">Loading...</p>
+                </div>
+              `
+            : html`
+                <!-- Current HEAD info -->
+                ${this._headCommit
+                  ? html`
+                      <div class="mb-3 p-2 bg-gray-50 rounded">
+                        <p class="text-xs">
+                          <span class="text-gray-600 font-mono"
+                            >${this._headCommit.hash.substring(0, 7)}</span
+                          >
+                          <span class="text-gray-800 ml-2"
+                            >${this._headCommit.subject}</span
+                          >
+                        </p>
+                      </div>
+                    `
+                  : ""}
+
+                <!-- Remote selection -->
+                ${this._renderRemoteSelection()}
+
+                <!-- Branch input -->
+                <div class="mb-3">
+                  <label class="block text-xs font-medium mb-1">Branch:</label>
+                  <input
+                    type="text"
+                    .value=${this._branch}
+                    ?disabled=${this._loading}
+                    @input=${(e: Event) => {
+                      this._branch = (e.target as HTMLInputElement).value;
+                    }}
+                    placeholder="Enter branch name..."
+                    class="w-full p-2 border border-gray-300 rounded text-xs focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
+                  />
+                </div>
+
+                <!-- Action buttons -->
+                <div class="flex gap-2 mb-3">
+                  <button
+                    @click=${(e: Event) => this._handlePush(true, e)}
+                    ?disabled=${!this._selectedRemote ||
+                    !this._branch ||
+                    !this._headCommit ||
+                    this._loading}
+                    class="flex-1 px-3 py-1.5 bg-gray-600 hover:bg-gray-700 disabled:bg-gray-400 text-white rounded text-xs transition-colors flex items-center justify-center"
+                  >
+                    ${this._pushingAction === "dry-run"
+                      ? html`
+                          <div
+                            class="animate-spin rounded-full h-3 w-3 border-b border-white mr-1"
+                          ></div>
+                        `
+                      : ""}
+                    Dry Run
+                  </button>
+                  <button
+                    @click=${(e: Event) => this._handlePush(false, e)}
+                    ?disabled=${!this._selectedRemote ||
+                    !this._branch ||
+                    !this._headCommit ||
+                    this._loading}
+                    class="flex-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded text-xs transition-colors flex items-center justify-center"
+                  >
+                    ${this._pushingAction === "push"
+                      ? html`
+                          <div
+                            class="animate-spin rounded-full h-3 w-3 border-b border-white mr-1"
+                          ></div>
+                        `
+                      : ""}
+                    Push
+                  </button>
+                </div>
+
+                <!-- Push result -->
+                ${this._pushResult
+                  ? html`
+                      <div
+                        class="p-3 rounded ${this._pushResult.success
+                          ? "bg-green-50 border border-green-200"
+                          : "bg-red-50 border border-red-200"} relative"
+                      >
+                        ${this._loading
+                          ? html`
+                              <div
+                                class="absolute inset-0 bg-white bg-opacity-75 flex items-center justify-center rounded"
+                              >
+                                <div
+                                  class="flex items-center text-xs text-gray-600"
+                                >
+                                  <div
+                                    class="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-600 mr-2"
+                                  ></div>
+                                  Processing...
+                                </div>
+                              </div>
+                            `
+                          : ""}
+
+                        <div class="flex items-center justify-between mb-2">
+                          <p
+                            class="text-xs font-medium ${this._pushResult
+                              .success
+                              ? "text-green-800"
+                              : "text-red-800"}"
+                          >
+                            ${this._pushResult.dry_run ? "Dry Run" : "Push"}
+                            ${this._pushResult.success
+                              ? "Successful"
+                              : "Failed"}
+                          </p>
+                          ${this._pushResult.success &&
+                          !this._pushResult.dry_run
+                            ? (() => {
+                                const branchURL = this._computeBranchURL();
+                                return branchURL
+                                  ? html`
+                                      <a
+                                        href="${branchURL}"
+                                        target="_blank"
+                                        class="inline-flex items-center gap-1 px-2 py-1 text-xs bg-gray-900 hover:bg-gray-800 text-white rounded transition-colors"
+                                      >
+                                        <svg
+                                          class="w-3 h-3"
+                                          viewBox="0 0 24 24"
+                                          fill="currentColor"
+                                        >
+                                          <path
+                                            d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"
+                                          />
+                                        </svg>
+                                        Open on GitHub
+                                      </a>
+                                    `
+                                  : "";
+                              })()
+                            : ""}
+                        </div>
+                        ${this._pushResult.output
+                          ? html`
+                              <pre
+                                class="text-xs text-gray-700 whitespace-pre-wrap font-mono mb-2 break-words"
+                                .innerHTML="${this._makeLinksClickable(
+                                  this._pushResult.output,
+                                )}"
+                              ></pre>
+                            `
+                          : ""}
+                        ${this._pushResult.error
+                          ? html`
+                              <p class="text-xs text-red-700 mb-2">
+                                ${this._pushResult.error}
+                              </p>
+                            `
+                          : ""}
+
+                        <div class="flex gap-2 items-center">
+                          ${!this._pushResult.success
+                            ? html`
+                                <button
+                                  @click=${(e: Event) => this._handleRebase(e)}
+                                  class="px-3 py-1 bg-orange-600 hover:bg-orange-700 text-white text-xs rounded transition-colors"
+                                >
+                                  Ask Agent to Rebase
+                                </button>
+                              `
+                            : ""}
+
+                          <button
+                            @click=${(e: Event) => {
+                              e.stopPropagation();
+                              this._closeModal();
+                            }}
+                            class="px-3 py-1 bg-gray-600 hover:bg-gray-700 text-white text-xs rounded transition-colors ml-auto"
+                          >
+                            Close
+                          </button>
+                        </div>
+                      </div>
+                    `
+                  : this._loading
+                    ? html`
+                        <div
+                          class="p-3 rounded bg-gray-50 border border-gray-200"
+                        >
+                          <div class="flex items-center text-xs text-gray-600">
+                            <div
+                              class="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-600 mr-2"
+                            ></div>
+                            Processing...
+                          </div>
+                        </div>
+                      `
+                    : ""}
+              `}
+        </div>
+      </div>
+    `;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    "sketch-push-button": SketchPushButton;
+  }
+}