diff --git a/claude-mux/internal/tmux/tmux.go b/claude-mux/internal/tmux/tmux.go index 606ab2a..2d134c7 100644 --- a/claude-mux/internal/tmux/tmux.go +++ b/claude-mux/internal/tmux/tmux.go @@ -389,6 +389,17 @@ func (s *Server) WindowSessionID(paneTarget string) string { return strings.TrimSpace(out) } +// PanePID returns the pid of the command tmux started in paneTarget, or "" when +// it cannot be resolved. Windows are launched with an `exec`, so for a Claude +// window this is the pid of Claude itself. +func (s *Server) PanePID(paneTarget string) string { + out, err := s.run("display-message", "-p", "-t", paneTarget, "#{pane_pid}") + if err != nil { + return "" + } + return strings.TrimSpace(out) +} + // hasSession reports whether a session named slug exists. func (s *Server) hasSession(slug string) bool { _, err := s.run("has-session", "-t", slug) diff --git a/claude-mux/main.go b/claude-mux/main.go index e79b200..fb36de9 100644 --- a/claude-mux/main.go +++ b/claude-mux/main.go @@ -266,9 +266,17 @@ func cmdHook(args []string) error { // reads as closed). Re-tagging on every hook self-heals that: the window now // carries the live id, and the id it used to host is no longer live here, so // its stale state is cleared rather than left to accumulate. + // + // Only the Claude that *owns* the pane may retag it. A Claude started from + // inside another one (a `claude -p` run from a Bash tool, say) inherits its + // TMUX_PANE, and would otherwise steal the tag from the session actually + // running in that window — dropping it to "closed" in the picker until its + // next hook fires. Claude exports CLAUDE_PID to its children, so the hook can + // tell the two apart: for a window claude-mux launched the pane command execs + // Claude, so the pane's pid is the owning Claude's pid. if pane := os.Getenv("TMUX_PANE"); pane != "" { srv := tmux.New() - if srv.InsideOurServer() { + if srv.InsideOurServer() && os.Getenv("CLAUDE_PID") == srv.PanePID(pane) { if old := srv.WindowSessionID(pane); old != "" && old != payload.SessionID { _ = state.Clear(old) } diff --git a/modules/cli/home.nix b/modules/cli/home.nix index 039d089..8b2b548 100644 --- a/modules/cli/home.nix +++ b/modules/cli/home.nix @@ -18,6 +18,60 @@ configDir = "${config.xdg.configHome}/claude"; # settings.json is installed as a writable file in the activation below instead # Claude needs to update it at runtime (e.g. model effort via the slash command). + + skills.new-session = '' + --- + name: new-session + description: Spawn a separate Claude Code session to work on a described task, handing it a self-contained prompt built from the current conversation. Use when the user asks to "create/spawn/start a new session", "open a new claude for X", "hand this off to another session", or "do that in a separate session". + --- + + # New session + + Spawn a fresh Claude Code session that works on what the user described, + in parallel with this one. It runs detached in the background and is + resumable like any other session, so the user picks it up when it is done. + + ## Steps + + 1. Work out the task and the project directory — the new session runs in + whatever directory you launch it from, so `cd` there. Default to the + current working directory. Only ask the user something if the task + itself is ambiguous, not to confirm details you can read from the + conversation or the repo. + + 2. Write the prompt to a file (the scratchpad dir if this session has one, + otherwise `mktemp`). The new session shares **nothing** with this one + and nobody is watching it work, so the prompt must stand on its own and + must not ask questions back. Include, in plain prose: + - the goal, stated as a concrete task; + - the background from this conversation the new session would otherwise + have to rediscover (what was tried, what failed, decisions already + made and why); + - the files that matter, as absolute or repo-relative paths — point at + files rather than pasting large chunks of them; + - constraints and non-goals: what must not change, what is out of scope; + - how to know it worked (a command to run, a behaviour to check). + + Do not restate the user's global CLAUDE.md rules — the new session + reads them itself. Write it as instructions to the new session, not as + a summary of this conversation. + + 3. Launch it detached, with an id of your own so you can report it: + + ```sh + id=$(uuidgen) + setsid claude -p --session-id "$id" "$(cat /path/to/prompt.md)" >"/tmp/claude-$id.log" 2>&1 & + echo "$id" + ``` + + `setsid` keeps it alive after the command returns, and it must be + backgrounded — do not wait for it. + + 4. Tell the user, briefly, what the new session was told to do, and give + them the session id (they resume it with `claude --resume `, and it + is in their session picker). Do not do the task yourself afterwards + unless the user asks. + ''; }; home.activation.claudeWritableSettings = '' diff --git a/nvim/lua/jj-diff.lua b/nvim/lua/jj-diff.lua index 09cb400..cfe7f86 100644 --- a/nvim/lua/jj-diff.lua +++ b/nvim/lua/jj-diff.lua @@ -12,14 +12,21 @@ local function resolve(buf_id) if change_id then -- The path in the uri is repo-relative; jj resolves it from anywhere in -- the repo, so run from the current working directory. - return { rev = change_id .. "-", file = rel, cwd = vim.fn.getcwd() } + return { rev = change_id .. "-", from = change_id, file = rel, rel = rel, cwd = vim.fn.getcwd() } end local path = vim.uv.fs_realpath(name) if path == nil then return nil end - return { rev = "@-", file = path, cwd = vim.fs.dirname(path) } + local root = vim.fs.root(path, ".jj") + return { + rev = "@-", + from = "@", + file = path, + rel = root and path:sub(#root + 2), + cwd = vim.fs.dirname(path), + } end local function set_ref_text(buf_id) @@ -162,10 +169,71 @@ local function detach(buf_id) end end +-- Squash the given hunks into the revision we diff against. jj has no index to +-- stage into, so instead we rebuild what the parent should look like (the +-- reference text with only those hunks applied) and hand it to `jj squash -i` +-- through a diff editor that just copies our file over jj's right side. +local function apply_hunks(buf_id, hunks) + local target = resolve(buf_id) + if target == nil or target.rel == nil then + return + end + + local ref = vim.split(MiniDiff.get_buf_data(buf_id).ref_text, "\n") + -- mini.diff always terminates the reference text with a newline + if ref[#ref] == "" then + table.remove(ref) + end + local buf = vim.api.nvim_buf_get_lines(buf_id, 0, -1, false) + + -- Bottom-up so earlier hunks keep their reference line numbers. + local sorted = vim.deepcopy(hunks) + table.sort(sorted, function(a, b) return a.ref_start > b.ref_start end) + for _, hunk in ipairs(sorted) do + -- "add" hunks have no reference line, `ref_start` is the one above them + local from = hunk.ref_start + (hunk.ref_count == 0 and 1 or 0) + local to = from + hunk.ref_count - 1 + local out = vim.list_slice(ref, 1, from - 1) + vim.list_extend(out, buf, hunk.buf_start, hunk.buf_start + hunk.buf_count - 1) + vim.list_extend(out, ref, to + 1, #ref) + ref = out + end + + local tmp = vim.fn.tempname() + vim.fn.writefile(ref, tmp) + vim.system({ + "jj", + "squash", + "--from", + target.from, + "--into", + target.rev, + "--use-destination-message", + "--tool", + "minidiff", + "--config", + 'merge-tools.minidiff.program="cp"', + "--config", + string.format( + "merge-tools.minidiff.edit-args=[%s, %s]", + vim.json.encode(tmp), + vim.json.encode("$right/" .. target.rel) + ), + "--", + target.rel, + }, { cwd = target.cwd, text = true }, vim.schedule_wrap(function(obj) + vim.fn.delete(tmp) + if obj.code ~= 0 then + vim.notify(obj.stderr, vim.log.levels.ERROR) + end + end)) +end + M.source = { name = "jj", attach = attach, detach = detach, + apply_hunks = apply_hunks, } -- support jj:// buffers diff --git a/nvim/lua/plugins/git.lua b/nvim/lua/plugins/git.lua index 1b0a5cc..0b4ce9c 100644 --- a/nvim/lua/plugins/git.lua +++ b/nvim/lua/plugins/git.lua @@ -36,7 +36,7 @@ return { require("mini.diff").gen_source.git(), }, mappings = { - apply = "", + apply = "gh", reset = "gH", textobject = "ih", goto_first = "[H", @@ -53,6 +53,9 @@ return { vim.keymap.set("n", "hr", "gHih", { remap = true, desc = "Reset Hunk" }) vim.keymap.set("x", "hr", "gH", { remap = true, desc = "Reset Hunk" }) + vim.keymap.set("n", "hs", "ghih", { remap = true, desc = "Squash Hunk" }) + vim.keymap.set("x", "hs", "gh", { remap = true, desc = "Squash Hunk" }) + vim.keymap.set("n", "hp", function() diff.toggle_overlay(0) end, { desc = "Preview Hunk (overlay)" }) vim.keymap.set("n", "gR", function() diff.do_hunks(0, "reset") end, { desc = "Reset Buffer" }) end,