Skip to content
Merged
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
88 changes: 88 additions & 0 deletions internal/dashboard/host_claude_config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package dashboard

import (
"os"
"path/filepath"

"github.com/scoutapp/corral/internal/config"
)

// The host interactive `claude` (the host-Claude terminal) must write its session
// transcripts somewhere the conversation tailer can attribute UNAMBIGUOUSLY to the
// host origin. By default Claude Code writes them to ~/.claude/projects/<slug>/,
// the SAME dir the sandbox's Claude writes to via the bind mount — so on disk the
// two are indistinguishable.
//
// Fix: point the host terminal at a DEDICATED CLAUDE_CONFIG_DIR (hostClaudeConfigDir)
// so its transcripts land in <dir>/projects/, a tree corral owns and the tailer
// reads as host-origin. CLAUDE_CONFIG_DIR relocates the WHOLE config dir, not just
// projects/, so we seed the dedicated dir by symlinking every entry of ~/.claude
// into it EXCEPT projects/ — auth (Linux .credentials.json), settings, plugins,
// skills, agents, etc. all remain shared, so /login, settings, and skills behave
// exactly as in the user's normal claude; only the transcript stream diverges.
// (On macOS auth is in the Keychain and shared regardless.)

// hostClaudeConfigDir is the dedicated CLAUDE_CONFIG_DIR for host-terminal claude.
func hostClaudeConfigDir() string {
return filepath.Join(config.CorralHome(), "host-claude-config")
}

// hostClaudeProjectsDir is where host-terminal claude writes its transcripts —
// the tailer reads this tree as host-origin.
func hostClaudeProjectsDir() string {
return filepath.Join(hostClaudeConfigDir(), "projects")
}

// ensureHostClaudeConfigDir creates the dedicated config dir and (re)links every
// ~/.claude entry except projects/ into it, so the host terminal shares the user's
// auth/settings/plugins but keeps a separate transcript tree. Idempotent and
// best-effort: it's called on each host-terminal open, refreshing links so a newly
// added ~/.claude entry (e.g. a freshly installed plugin) shows up without a
// restart. Returns the config dir, or "" if it couldn't be prepared.
func ensureHostClaudeConfigDir() string {
cfgDir := hostClaudeConfigDir()
if cfgDir == "" {
return ""
}
if err := os.MkdirAll(cfgDir, 0o755); err != nil {
return ""
}
// Our own real projects/ dir — NOT a link to ~/.claude/projects, that's the
// whole point of the separation.
if err := os.MkdirAll(filepath.Join(cfgDir, "projects"), 0o755); err != nil {
return ""
}

home, err := os.UserHomeDir()
if err != nil {
return cfgDir // no source to link; a bare config dir still works (fresh login)
}
src := filepath.Join(home, ".claude")
entries, err := os.ReadDir(src)
if err != nil {
return cfgDir // ~/.claude absent (fresh machine); nothing to share yet
}
for _, e := range entries {
name := e.Name()
if name == "projects" {
continue // keep transcripts separate
}
linkPath := filepath.Join(cfgDir, name)
target := filepath.Join(src, name)
// Refresh: if a link already points at target, leave it; otherwise (re)create.
// Only ever replace a symlink we own — never clobber a real file/dir a user
// might have placed here.
if existing, lerr := os.Readlink(linkPath); lerr == nil {
if existing == target {
continue
}
_ = os.Remove(linkPath)
} else if _, serr := os.Lstat(linkPath); serr == nil {
// A non-symlink already sits here (user-placed or a stale real dir); don't
// touch it.
continue
}
_ = os.Symlink(target, linkPath)
}
return cfgDir
}
99 changes: 99 additions & 0 deletions internal/dashboard/host_claude_config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package dashboard

import (
"os"
"path/filepath"
"testing"
)

// ensureHostClaudeConfigDir must give the host terminal a SEPARATE projects/ tree
// (so its transcripts don't collide with the sandbox's) while SHARING everything
// else from ~/.claude via symlinks (so auth/settings/plugins carry over).
func TestEnsureHostClaudeConfigDir(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
// CorralHome honors $CORRAL_HOME; point it inside the temp home so the config
// dir lands somewhere disposable.
t.Setenv("CORRAL_HOME", filepath.Join(home, ".corral"))

// Seed a realistic ~/.claude: a shared file, a shared dir, and the projects/
// dir that must NOT be linked.
claude := filepath.Join(home, ".claude")
mustMkdir(t, filepath.Join(claude, "plugins"))
mustMkdir(t, filepath.Join(claude, "projects", "-some-old-slug"))
mustWrite(t, filepath.Join(claude, "settings.json"), `{"x":1}`)
mustWrite(t, filepath.Join(claude, ".credentials.json"), `{"token":"t"}`)

cfgDir := ensureHostClaudeConfigDir()
if cfgDir == "" {
t.Fatal("ensureHostClaudeConfigDir returned empty")
}

// projects/ is a REAL dir we own, not a link to ~/.claude/projects.
projects := filepath.Join(cfgDir, "projects")
if fi, err := os.Lstat(projects); err != nil || fi.Mode()&os.ModeSymlink != 0 {
t.Fatalf("projects/ must be a real dir, got err=%v mode=%v", err, fi)
}
// And it must be empty of the sandbox's old slug — separation, not a mirror.
if _, err := os.Stat(filepath.Join(projects, "-some-old-slug")); err == nil {
t.Fatal("projects/ leaked the sandbox's transcript dir")
}

// settings.json and .credentials.json are symlinks pointing back at ~/.claude.
for _, name := range []string{"settings.json", ".credentials.json", "plugins"} {
lp := filepath.Join(cfgDir, name)
target, err := os.Readlink(lp)
if err != nil {
t.Fatalf("%s should be a symlink: %v", name, err)
}
if want := filepath.Join(claude, name); target != want {
t.Fatalf("%s links to %q, want %q", name, target, want)
}
}

// Idempotent: a second call doesn't error or duplicate.
if again := ensureHostClaudeConfigDir(); again != cfgDir {
t.Fatalf("second call returned %q, want %q", again, cfgDir)
}
}

// A non-symlink already sitting in the config dir (user-placed) must be left alone.
func TestEnsureHostClaudeConfigDirDoesNotClobber(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("CORRAL_HOME", filepath.Join(home, ".corral"))
mustWrite(t, filepath.Join(home, ".claude", "settings.json"), `{}`)

cfgDir := hostClaudeConfigDir()
mustMkdir(t, cfgDir)
// A real file the user dropped in, same name as a ~/.claude entry.
real := filepath.Join(cfgDir, "settings.json")
mustWrite(t, real, "USER DATA")

ensureHostClaudeConfigDir()

if fi, err := os.Lstat(real); err != nil || fi.Mode()&os.ModeSymlink != 0 {
t.Fatalf("user file was clobbered into a symlink: err=%v", err)
}
b, _ := os.ReadFile(real)
if string(b) != "USER DATA" {
t.Fatalf("user file content changed to %q", string(b))
}
}

func mustMkdir(t *testing.T, p string) {
t.Helper()
if err := os.MkdirAll(p, 0o755); err != nil {
t.Fatal(err)
}
}

func mustWrite(t *testing.T, p, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
109 changes: 77 additions & 32 deletions internal/dashboard/sandbox_conv_tail.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,30 +48,45 @@ func claudeProjectsDir() string {
}

// startSandboxConvTail launches the host-pull tailer: every ~2s it mirrors any
// new session-transcript lines from running projects into the conversations DB.
// Best-effort throughout — an unopenable convstore (e.g. a tag-less dev build)
// or a missing session dir just means nothing is captured.
// new session-transcript lines into the conversations DB — from running sandboxes
// (the Container tab's Claude) AND from the host's own interactive `claude`
// terminals (the host-Claude terminal that replaces the bespoke ChatDock). Both
// write the same ~/.claude/projects/<slug>/<sid>.jsonl transcripts, so one tailer
// covers both; they differ only in which workspaces to sweep and how the resulting
// conversation is stamped (origin kind + conv-key prefix + parent resolver).
// Best-effort throughout — an unopenable convstore (e.g. a tag-less dev build) or
// a missing session dir just means nothing is captured.
func (d *dashboardServer) startSandboxConvTail() {
go func() {
t := time.NewTicker(2 * time.Second)
defer t.Stop()
st := &sandboxTailState{offsets: map[string]int64{}, convIDs: map[string]int64{}}
for {
d.tailSandboxConversationsOnce(st)
d.tailConversationsOnce(st)
<-t.C
}
}()
}

// sandboxTailState is the tailer's in-memory bookkeeping: byte offset per
// transcript file, and the conversation id per Claude session id.
// transcript file, and the conversation id per (origin-scoped) Claude session id.
type sandboxTailState struct {
offsets map[string]int64 // file path → bytes already consumed
convIDs map[string]int64 // claude session id → convstore conversation id
convIDs map[string]int64 // "<origin>:<claude session id>" → convstore conversation id
}

// tailSandboxConversationsOnce does one sweep over running projects' transcripts.
func (d *dashboardServer) tailSandboxConversationsOnce(st *sandboxTailState) {
// convOriginProfile captures how a swept transcript's conversation is stamped —
// the one axis on which the sandbox pull and the host-terminal pull differ. The
// file-tail and line-ingest machinery is otherwise identical for both.
type convOriginProfile struct {
kind string // convstore OriginKind (e.g. "sandbox", "host-terminal")
keyPrefix string // conv-key namespace so the same session id can't collide across origins
parent func(workspace string) int64 // resolves the spawning conversation id, or 0
}

// tailConversationsOnce does one sweep over both sandbox and host-Claude
// transcripts.
func (d *dashboardServer) tailConversationsOnce(st *sandboxTailState) {
cs, err := d.getConvStore()
if err != nil {
return
Expand All @@ -84,28 +99,54 @@ func (d *dashboardServer) tailSandboxConversationsOnce(st *sandboxTailState) {
if err != nil {
return
}

sandboxProfile := convOriginProfile{
kind: "sandbox",
keyPrefix: "sandbox",
parent: d.sandboxParentConv,
}
// The host interactive `claude` runs with a DEDICATED CLAUDE_CONFIG_DIR
// (hostClaudeProjectsDir), so its transcripts live in their own tree — cleanly
// separated from the sandbox's ~/.claude/projects, no on-disk ambiguity.
hostProfile := convOriginProfile{
kind: "host-terminal",
keyPrefix: "host",
parent: d.sandboxParentConv, // same project-level parent linkage
}

for _, p := range reg.Projects {
ws := p.Workspace
if !session.DockerContainerRunning(session.ContainerNameForWorkspace(ws)) {
continue // only tail live sandboxes
// Sandbox transcripts: shared ~/.claude/projects, only while the container is up.
if session.DockerContainerRunning(session.ContainerNameForWorkspace(ws)) {
d.tailProjectDir(cs, st, sandboxProfile, ws, filepath.Join(dir, claudeProjectSlug(ws)))
}
sessDir := filepath.Join(dir, claudeProjectSlug(ws))
entries, err := os.ReadDir(sessDir)
if err != nil {
continue
// Host-terminal transcripts: corral-owned tree, only while the -claude tmux
// session is live. Same <slug> subdir naming (cwd is the workspace either way).
if hostDir := hostClaudeProjectsDir(); hostDir != "" && hostSessionLive(claudeShellSession(ws)) {
d.tailProjectDir(cs, st, hostProfile, ws, filepath.Join(hostDir, claudeProjectSlug(ws)))
}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
continue
}
d.tailSandboxFile(cs, st, ws, filepath.Join(sessDir, e.Name()))
}
}

// tailProjectDir ingests every .jsonl transcript in one <slug> session directory
// under the given origin profile.
func (d *dashboardServer) tailProjectDir(cs *convstore.ConvStore, st *sandboxTailState, profile convOriginProfile, workspace, sessDir string) {
entries, err := os.ReadDir(sessDir)
if err != nil {
return
}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
continue
}
d.tailTranscriptFile(cs, st, profile, workspace, filepath.Join(sessDir, e.Name()))
}
}

// tailSandboxFile reads new lines from one session transcript and appends them to
// the conversations DB, tracking the byte offset so each line is consumed once.
func (d *dashboardServer) tailSandboxFile(cs *convstore.ConvStore, st *sandboxTailState, workspace, path string) {
// tailTranscriptFile reads new lines from one session transcript and appends them
// to the conversations DB under the given origin profile, tracking the byte offset
// so each line is consumed once.
func (d *dashboardServer) tailTranscriptFile(cs *convstore.ConvStore, st *sandboxTailState, profile convOriginProfile, workspace, path string) {
fi, err := os.Stat(path)
if err != nil {
return
Expand All @@ -129,7 +170,7 @@ func (d *dashboardServer) tailSandboxFile(cs *convstore.ConvStore, st *sandboxTa
for sc.Scan() {
line := sc.Bytes()
consumed += int64(len(line)) + 1 // + newline
d.ingestSandboxLine(cs, st, workspace, line)
d.ingestTranscriptLine(cs, st, profile, workspace, line)
}
// Advance the offset only by what we actually scanned (a final partial line,
// mid-write, is left for the next sweep by not counting it — Scanner drops an
Expand All @@ -156,11 +197,11 @@ type claudeContentBlock struct {
ToolUseID string `json:"tool_use_id"`
}

// ingestSandboxLine parses one Claude Code session record and appends its
// ingestTranscriptLine parses one Claude Code session record and appends its
// message(s) to the conversation for its session id (creating the conversation
// on first sight). Non-conversational record types (mode, snapshots, …) are
// skipped. Best-effort.
func (d *dashboardServer) ingestSandboxLine(cs *convstore.ConvStore, st *sandboxTailState, workspace string, line []byte) {
// on first sight, stamped per the origin profile). Non-conversational record
// types (mode, snapshots, …) are skipped. Best-effort.
func (d *dashboardServer) ingestTranscriptLine(cs *convstore.ConvStore, st *sandboxTailState, profile convOriginProfile, workspace string, line []byte) {
var rec claudeSessionRecord
if json.Unmarshal(line, &rec) != nil {
return
Expand All @@ -169,22 +210,26 @@ func (d *dashboardServer) ingestSandboxLine(cs *convstore.ConvStore, st *sandbox
return
}

convID := st.convIDs[rec.SessionID]
// Key the conv map by origin+session so the same session id under two origins
// (it won't happen now that dirs are separate, but keep it unambiguous) maps to
// distinct conversations.
convKey := profile.keyPrefix + ":" + rec.SessionID
convID := st.convIDs[convKey]
if convID == 0 {
id, err := cs.StartConversation(convstore.ConvMeta{
ConvKey: "sandbox:" + rec.SessionID,
ConvKey: convKey,
ClaudeSessionID: rec.SessionID,
OriginKind: "sandbox",
OriginKind: profile.kind,
OriginID: rec.SessionID,
ProjectID: ProjectID(workspace),
ProjectLabel: filepath.Base(workspace),
ParentConversationID: d.sandboxParentConv(workspace),
ParentConversationID: profile.parent(workspace),
})
if err != nil {
return
}
convID = id
st.convIDs[rec.SessionID] = id
st.convIDs[convKey] = id
// Resume across restarts: if this session already has messages from a prior
// run, skip re-ingesting by seeding the offset is handled at the file level;
// here we simply continue appending (dup lines are avoided by the byte
Expand Down
13 changes: 12 additions & 1 deletion internal/dashboard/terminal.go
Original file line number Diff line number Diff line change
Expand Up @@ -371,11 +371,22 @@ func (d *dashboardServer) handleClaudeHostWS(w http.ResponseWriter, r *http.Requ
// Start the session with `claude` as its command (not a shell that we then
// type into): the pane IS the TUI, so a stray shell can't linger behind it,
// and if claude exits the pane closes rather than dropping to a prompt.
//
// Point it at a DEDICATED CLAUDE_CONFIG_DIR so its transcripts land in a tree
// the conversation tailer attributes to the host origin (the sandbox's Claude
// otherwise writes to the same ~/.claude/projects/<slug> dir, indistinguishable
// on disk). The dir shares auth/settings/plugins with ~/.claude via symlinks —
// only projects/ diverges. `exec` so claude is the pane's process directly.
cfgDir := ensureHostClaudeConfigDir()
launch := "exec " + shellQuote(claudeBin)
if cfgDir != "" {
launch = "CLAUDE_CONFIG_DIR=" + shellQuote(cfgDir) + " " + launch
}
args := []string{"new-session", "-d", "-s", sessionName}
if dir != "" {
args = append(args, "-c", dir)
}
args = append(args, claudeBin)
args = append(args, "sh", "-c", launch)
if hostTmux(args...).Run() == nil {
hostTmux("set-option", "-t", sessionName, "status", "off").Run()
hostTmux("set-option", "-t", sessionName, "mouse", "on").Run()
Expand Down
Loading