diff --git a/internal/dashboard/host_claude_config.go b/internal/dashboard/host_claude_config.go new file mode 100644 index 0000000..5ad071c --- /dev/null +++ b/internal/dashboard/host_claude_config.go @@ -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//, +// 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 /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 +} diff --git a/internal/dashboard/host_claude_config_test.go b/internal/dashboard/host_claude_config_test.go new file mode 100644 index 0000000..4f67ede --- /dev/null +++ b/internal/dashboard/host_claude_config_test.go @@ -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) + } +} diff --git a/internal/dashboard/sandbox_conv_tail.go b/internal/dashboard/sandbox_conv_tail.go index ca527fd..207d03d 100644 --- a/internal/dashboard/sandbox_conv_tail.go +++ b/internal/dashboard/sandbox_conv_tail.go @@ -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//.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 // ":" → 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 @@ -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 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 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 @@ -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 @@ -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 @@ -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 diff --git a/internal/dashboard/terminal.go b/internal/dashboard/terminal.go index 117af64..1443591 100644 --- a/internal/dashboard/terminal.go +++ b/internal/dashboard/terminal.go @@ -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/ 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()