sketch: Propagate host vs. runtime OS/WorkingDir/Hostname

If you have a bunch of sketch sessions, you need to know where they were
launched. The container hostnames (some random thing) and working dirs (always /app)
aren't very helpful, so we want to keep around both. I've updated the UI
to show them as well.

This commit chooses "Host" and "Runtime" as the names of the "Outside"
and "Inside" sketch. I'm open to suggestions for better names.

Co-Authored-By: sketch, but it did a so-so job
diff --git a/cmd/bundle-analyzer/main.go b/cmd/bundle-analyzer/main.go
index ead3ff5..2910a7e 100644
--- a/cmd/bundle-analyzer/main.go
+++ b/cmd/bundle-analyzer/main.go
@@ -24,7 +24,7 @@
 	flag.Parse()
 
 	// Ensure the output directory exists
-	if err := os.MkdirAll(*outputDir, 0755); err != nil {
+	if err := os.MkdirAll(*outputDir, 0o755); err != nil {
 		fmt.Fprintf(os.Stderr, "Error creating output directory: %v\n", err)
 		os.Exit(1)
 	}
diff --git a/cmd/sketch/main.go b/cmd/sketch/main.go
index b7f9fd3..7b0c34a 100644
--- a/cmd/sketch/main.go
+++ b/cmd/sketch/main.go
@@ -41,20 +41,26 @@
 	unsafe := flag.Bool("unsafe", false, "run directly without a docker container")
 	openBrowser := flag.Bool("open", false, "open sketch URL in system browser")
 	httprrFile := flag.String("httprr", "", "if set, record HTTP interactions to file")
-	record := flag.Bool("httprecord", true, "Record trace (if httprr is set)")
 	maxIterations := flag.Uint64("max-iterations", 0, "maximum number of iterations the agent should perform per turn, 0 to disable limit")
 	maxWallTime := flag.Duration("max-wall-time", 0, "maximum time the agent should run per turn, 0 to disable limit")
 	maxDollars := flag.Float64("max-dollars", 5.0, "maximum dollars the agent should spend per turn, 0 to disable limit")
 	one := flag.Bool("one", false, "run a single iteration and exit without termui")
-	sessionID := flag.String("session-id", newSessionID(), "unique session-id for a sketch process")
-	gitUsername := flag.String("git-username", "", "username for git commits")
-	gitEmail := flag.String("git-email", "", "email for git commits")
 	verbose := flag.Bool("verbose", false, "enable verbose output")
 	version := flag.Bool("version", false, "print the version and exit")
-	noCleanup := flag.Bool("nocleanup", false, "do not clean up docker containers on exit")
-	containerLogDest := flag.String("save-container-logs", "", "host path to save container logs to on exit")
-	sketchBinaryLinux := flag.String("sketch-binary-linux", "", "path to a pre-built sketch binary for linux")
 	workingDir := flag.String("C", "", "when set, change to this directory before running")
+
+	// Flags geared towards sketch developers or sketch internals:
+	gitUsername := flag.String("git-username", "", "(internal) username for git commits")
+	gitEmail := flag.String("git-email", "", "(internal) email for git commits")
+	sessionID := flag.String("session-id", newSessionID(), "(internal) unique session-id for a sketch process")
+	record := flag.Bool("httprecord", true, "(debugging) Record trace (if httprr is set)")
+	noCleanup := flag.Bool("nocleanup", false, "(debugging) do not clean up docker containers on exit")
+	containerLogDest := flag.String("save-container-logs", "", "(debugging) host path to save container logs to on exit")
+	hostHostname := flag.String("host-hostname", "", "(internal) hostname on the host")
+	hostOS := flag.String("host-os", "", "(internal) OS on the host")
+	hostWorkingDir := flag.String("host-working-dir", "", "(internal) workign dir on the host")
+	sketchBinaryLinux := flag.String("sketch-binary-linux", "", "(development) path to a pre-built sketch binary for linux")
+
 	flag.Parse()
 
 	if *version {
@@ -189,6 +195,9 @@
 			SketchBinaryLinux: *sketchBinaryLinux,
 			SketchPubKey:      pubKey,
 			ForceRebuild:      false,
+			HostHostname:      getHostname(),
+			HostOS:            runtime.GOOS,
+			HostWorkingDir:    cwd,
 		}
 		if err := dockerimg.LaunchContainer(ctx, stdout, stderr, config); err != nil {
 			if *verbose {
@@ -236,6 +245,9 @@
 		ClientGOOS:       runtime.GOOS,
 		ClientGOARCH:     runtime.GOARCH,
 		UseAnthropicEdit: os.Getenv("SKETCH_ANTHROPIC_EDIT") == "1",
+		HostHostname:     *hostHostname,
+		HostOS:           *hostOS,
+		HostWorkingDir:   *hostWorkingDir,
 	}
 	agent := loop.NewAgent(agentConfig)
 
@@ -341,6 +353,14 @@
 	return s[0:4] + "-" + s[4:8] + "-" + s[8:12] + "-" + s[12:16]
 }
 
+func getHostname() string {
+	hostname, err := os.Hostname()
+	if err != nil {
+		return "unknown"
+	}
+	return hostname
+}
+
 func defaultGitUsername() string {
 	out, err := exec.Command("git", "config", "user.name").CombinedOutput()
 	if err != nil {
diff --git a/dockerimg/dockerimg.go b/dockerimg/dockerimg.go
index 01a7392..6043c23 100644
--- a/dockerimg/dockerimg.go
+++ b/dockerimg/dockerimg.go
@@ -67,6 +67,11 @@
 
 	// Sketch client public key.
 	SketchPubKey string
+
+	// Host information to pass to the container
+	HostHostname   string
+	HostOS         string
+	HostWorkingDir string
 }
 
 // LaunchContainer creates a docker container for a project, installs sketch and opens a connection to it.
@@ -223,7 +228,7 @@
 			return nil
 		}
 		out, logsErr := combinedOutput(ctx, "docker", "logs", cntrName)
-		if err != nil {
+		if logsErr != nil {
 			return fmt.Errorf("%w; and docker logs failed: %s, %v", err, out, logsErr)
 		}
 		out = bytes.TrimSpace(out)
@@ -375,7 +380,11 @@
 		"-unsafe",
 		"-addr=:80",
 		"-session-id="+config.SessionID,
-		"-git-username="+config.GitUsername, "-git-email="+config.GitEmail,
+		"-git-username="+config.GitUsername,
+		"-git-email="+config.GitEmail,
+		"-host-hostname="+config.HostHostname,
+		"-host-os="+config.HostOS,
+		"-host-working-dir="+config.HostWorkingDir,
 	)
 	if config.SkabandAddr != "" {
 		cmdArgs = append(cmdArgs, "-skaband-addr="+config.SkabandAddr)
diff --git a/loop/agent.go b/loop/agent.go
index a14d244..2bd0776 100644
--- a/loop/agent.go
+++ b/loop/agent.go
@@ -73,6 +73,10 @@
 
 	// OS returns the operating system of the client.
 	OS() string
+	HostOS() string
+	HostHostname() string
+	HostWorkingDir() string
+	GitOrigin() string
 }
 
 type CodingAgentMessageType string
@@ -235,6 +239,12 @@
 	originalBudget ant.Budget
 	title          string
 	codereview     *claudetool.CodeReviewer
+	// Host information
+	hostHostname   string
+	hostOS         string
+	hostWorkingDir string
+	// URL of the git remote 'origin' if it exists
+	gitOrigin string
 
 	// Time when the current turn started (reset at the beginning of InnerLoop)
 	startOfTurn time.Time
@@ -284,6 +294,26 @@
 	return a.config.ClientGOOS
 }
 
+// HostOS returns the operating system of the host.
+func (a *Agent) HostOS() string {
+	return a.hostOS
+}
+
+// HostHostname returns the hostname of the host.
+func (a *Agent) HostHostname() string {
+	return a.hostHostname
+}
+
+// HostWorkingDir returns the working directory on the host.
+func (a *Agent) HostWorkingDir() string {
+	return a.hostWorkingDir
+}
+
+// GitOrigin returns the URL of the git remote 'origin' if it exists.
+func (a *Agent) GitOrigin() string {
+	return a.gitOrigin
+}
+
 // SetTitle sets the title of the conversation.
 func (a *Agent) SetTitle(title string) {
 	a.mu.Lock()
@@ -411,6 +441,10 @@
 	ClientGOOS       string
 	ClientGOARCH     string
 	UseAnthropicEdit bool
+	// Host information
+	HostHostname   string
+	HostOS         string
+	HostWorkingDir string
 }
 
 // NewAgent creates a new Agent.
@@ -424,6 +458,9 @@
 		startedAt:      time.Now(),
 		originalBudget: config.Budget,
 		seenCommits:    make(map[string]bool),
+		hostHostname:   config.HostHostname,
+		hostOS:         config.HostOS,
+		hostWorkingDir: config.HostWorkingDir,
 	}
 	return agent
 }
@@ -488,6 +525,8 @@
 			return fmt.Errorf("Agent.Init: claudetool.NewCodeReviewer: %w", err)
 		}
 		a.codereview = codereview
+
+		a.gitOrigin = getGitOrigin(ctx, ini.WorkingDir)
 	}
 	a.lastHEAD = a.initialCommit
 	a.convo = a.initConvo()
@@ -1133,3 +1172,16 @@
 
 	return true
 }
+
+// getGitOrigin returns the URL of the git remote 'origin' if it exists
+func getGitOrigin(ctx context.Context, dir string) string {
+	cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
+	cmd.Dir = dir
+	stderr := new(strings.Builder)
+	cmd.Stderr = stderr
+	out, err := cmd.Output()
+	if err != nil {
+		return ""
+	}
+	return strings.TrimSpace(string(out))
+}
diff --git a/loop/server/gzhandler/gzhandler_test.go b/loop/server/gzhandler/gzhandler_test.go
index 958f8ab..7fff5f4 100644
--- a/loop/server/gzhandler/gzhandler_test.go
+++ b/loop/server/gzhandler/gzhandler_test.go
@@ -16,23 +16,23 @@
 	testFS := fstest.MapFS{
 		"regular.txt": &fstest.MapFile{
 			Data: []byte("This is a regular text file"),
-			Mode: 0644,
+			Mode: 0o644,
 		},
 		"regular.txt.gz": &fstest.MapFile{
 			Data: compressString(t, "This is a regular text file"),
-			Mode: 0644,
+			Mode: 0o644,
 		},
 		"regular.js": &fstest.MapFile{
 			Data: []byte("console.log('Hello world');"),
-			Mode: 0644,
+			Mode: 0o644,
 		},
 		"regular.js.gz": &fstest.MapFile{
 			Data: compressString(t, "console.log('Hello world');"),
-			Mode: 0644,
+			Mode: 0o644,
 		},
 		"nogzip.css": &fstest.MapFile{
 			Data: []byte(".body { color: red; }"),
-			Mode: 0644,
+			Mode: 0o644,
 		},
 	}
 
@@ -165,15 +165,15 @@
 	// Create a test filesystem with a directory
 	testFS := fstest.MapFS{
 		"dir": &fstest.MapFile{
-			Mode: fs.ModeDir | 0755,
+			Mode: fs.ModeDir | 0o755,
 		},
 		"dir/index.html": &fstest.MapFile{
 			Data: []byte("<html>Directory index</html>"),
-			Mode: 0644,
+			Mode: 0o644,
 		},
 		"dir/index.html.gz": &fstest.MapFile{
 			Data: compressString(t, "<html>Directory index</html>"),
-			Mode: 0644,
+			Mode: 0o644,
 		},
 	}
 
diff --git a/loop/server/loophttp.go b/loop/server/loophttp.go
index 0660357..a814551 100644
--- a/loop/server/loophttp.go
+++ b/loop/server/loophttp.go
@@ -51,11 +51,19 @@
 type State struct {
 	MessageCount  int                  `json:"message_count"`
 	TotalUsage    *ant.CumulativeUsage `json:"total_usage,omitempty"`
-	Hostname      string               `json:"hostname"`
-	WorkingDir    string               `json:"working_dir"`
 	InitialCommit string               `json:"initial_commit"`
 	Title         string               `json:"title"`
-	OS            string               `json:"os"`
+	Hostname      string               `json:"hostname"`    // deprecated
+	WorkingDir    string               `json:"working_dir"` // deprecated
+	OS            string               `json:"os"`          // deprecated
+	GitOrigin     string               `json:"git_origin,omitempty"`
+
+	HostHostname      string `json:"host_hostname,omitempty"`
+	RuntimeHostname   string `json:"runtime_hostname,omitempty"`
+	HostOS            string `json:"host_os,omitempty"`
+	RuntimeOS         string `json:"runtime_os,omitempty"`
+	HostWorkingDir    string `json:"host_working_dir,omitempty"`
+	RuntimeWorkingDir string `json:"runtime_working_dir,omitempty"`
 }
 
 // Server serves sketch HTTP. Server implements http.Handler.
@@ -323,13 +331,20 @@
 		w.Header().Set("Content-Type", "application/json")
 
 		state := State{
-			MessageCount:  serverMessageCount,
-			TotalUsage:    &totalUsage,
-			Hostname:      s.hostname,
-			WorkingDir:    getWorkingDir(),
-			InitialCommit: agent.InitialCommit(),
-			Title:         agent.Title(),
-			OS:            agent.OS(),
+			MessageCount:      serverMessageCount,
+			TotalUsage:        &totalUsage,
+			Hostname:          s.hostname,
+			WorkingDir:        getWorkingDir(),
+			InitialCommit:     agent.InitialCommit(),
+			Title:             agent.Title(),
+			OS:                agent.OS(),
+			HostHostname:      agent.HostHostname(),
+			RuntimeHostname:   s.hostname,
+			HostOS:            agent.HostOS(),
+			RuntimeOS:         agent.OS(),
+			HostWorkingDir:    agent.HostWorkingDir(),
+			RuntimeWorkingDir: getWorkingDir(),
+			GitOrigin:         agent.GitOrigin(),
 		}
 
 		// Create a JSON encoder with indentation for pretty-printing
diff --git a/loop/webui/esbuild.go b/loop/webui/esbuild.go
index 03c745e..b86b7d7 100644
--- a/loop/webui/esbuild.go
+++ b/loop/webui/esbuild.go
@@ -334,7 +334,7 @@
 	defer os.RemoveAll(tmpDir)
 
 	// Create output directory if it doesn't exist
-	if err := os.MkdirAll(outputDir, 0755); err != nil {
+	if err := os.MkdirAll(outputDir, 0o755); err != nil {
 		return "", err
 	}
 
diff --git a/loop/webui/src/types.ts b/loop/webui/src/types.ts
index dfa89a7..68b6045 100644
--- a/loop/webui/src/types.ts
+++ b/loop/webui/src/types.ts
@@ -67,6 +67,13 @@
 	initial_commit: string;
 	title: string;
 	os: string;
+	host_hostname?: string;
+	runtime_hostname?: string;
+	host_os?: string;
+	runtime_os?: string;
+	host_working_dir?: string;
+	runtime_working_dir?: string;
+	git_origin?: string;
 }
 
 export type CodingAgentMessageType = 'user' | 'agent' | 'error' | 'budget' | 'tool' | 'commit' | 'auto';
diff --git a/loop/webui/src/web-components/sketch-container-status.ts b/loop/webui/src/web-components/sketch-container-status.ts
index e4c5802..c6aa811 100644
--- a/loop/webui/src/web-components/sketch-container-status.ts
+++ b/loop/webui/src/web-components/sketch-container-status.ts
@@ -54,6 +54,11 @@
       font-weight: 600;
     }
 
+    [title] {
+      cursor: help;
+      text-decoration: underline dotted;
+    }
+
     .cost {
       color: #2e7d32;
     }
@@ -69,6 +74,62 @@
     super();
   }
 
+  formatHostname() {
+    const hostHostname = this.state?.host_hostname;
+    const runtimeHostname = this.state?.runtime_hostname;
+
+    if (!hostHostname || !runtimeHostname) {
+      return this.state?.hostname;
+    }
+
+    if (hostHostname === runtimeHostname) {
+      return hostHostname;
+    }
+
+    return `${hostHostname}:${runtimeHostname}`;
+  }
+
+  formatWorkingDir() {
+    const hostWorkingDir = this.state?.host_working_dir;
+    const runtimeWorkingDir = this.state?.runtime_working_dir;
+
+    if (!hostWorkingDir || !runtimeWorkingDir) {
+      return this.state?.working_dir;
+    }
+
+    if (hostWorkingDir === runtimeWorkingDir) {
+      return hostWorkingDir;
+    }
+
+    return `${hostWorkingDir}:${runtimeWorkingDir}`;
+  }
+
+  getHostnameTooltip() {
+    const hostHostname = this.state?.host_hostname;
+    const runtimeHostname = this.state?.runtime_hostname;
+
+    if (!hostHostname || !runtimeHostname || hostHostname === runtimeHostname) {
+      return "";
+    }
+
+    return `Host: ${hostHostname}, Runtime: ${runtimeHostname}`;
+  }
+
+  getWorkingDirTooltip() {
+    const hostWorkingDir = this.state?.host_working_dir;
+    const runtimeWorkingDir = this.state?.runtime_working_dir;
+
+    if (
+      !hostWorkingDir ||
+      !runtimeWorkingDir ||
+      hostWorkingDir === runtimeWorkingDir
+    ) {
+      return "";
+    }
+
+    return `Host: ${hostWorkingDir}, Runtime: ${runtimeWorkingDir}`;
+  }
+
   // See https://lit.dev/docs/components/lifecycle/
   connectedCallback() {
     super.connectedCallback();
@@ -91,13 +152,33 @@
           <a href="download">Download</a>
         </div>
         <div class="info-item">
-          <span id="hostname" class="info-value">${this.state?.hostname}</span>
+          <span
+            id="hostname"
+            class="info-value"
+            title="${this.getHostnameTooltip()}"
+          >
+            ${this.formatHostname()}
+          </span>
         </div>
         <div class="info-item">
-          <span id="workingDir" class="info-value"
-            >${this.state?.working_dir}</span
+          <span
+            id="workingDir"
+            class="info-value"
+            title="${this.getWorkingDirTooltip()}"
           >
+            ${this.formatWorkingDir()}
+          </span>
         </div>
+        ${this.state?.git_origin
+          ? html`
+              <div class="info-item">
+                <span class="info-label">Origin:</span>
+                <span id="gitOrigin" class="info-value"
+                  >${this.state?.git_origin}</span
+                >
+              </div>
+            `
+          : ""}
         <div class="info-item">
           <span class="info-label">Commit:</span>
           <span id="initialCommit" class="info-value"