santree 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,74 @@
1
+ import { spawnSync } from "child_process";
2
+ import * as fs from "fs";
3
+ import * as os from "os";
4
+ import * as path from "path";
5
+ // GUI editors that detach by default and need a `--wait` flag to make spawnSync
6
+ // block until the file is closed. Terminal editors (vim, nvim, nano, emacs -nw)
7
+ // already block, so they aren't listed here.
8
+ const GUI_EDITORS_NEEDING_WAIT = new Set([
9
+ "zed",
10
+ "code",
11
+ "code-insiders",
12
+ "cursor",
13
+ "windsurf",
14
+ "subl",
15
+ ]);
16
+ /**
17
+ * Open the user's editor on a temp file seeded with `initial`, then return the
18
+ * saved content. Empty buffer is treated as cancel (matches `git commit`).
19
+ *
20
+ * Editor resolution: SANTREE_EDITOR > VISUAL > EDITOR > "vim".
21
+ */
22
+ export function editExternally(initial, ext = "md") {
23
+ const editorRaw = process.env["SANTREE_EDITOR"] || process.env["VISUAL"] || process.env["EDITOR"] || "vim";
24
+ const filePath = path.join(os.tmpdir(), `santree-edit-${Date.now()}.${ext.replace(/^\./, "")}`);
25
+ try {
26
+ fs.writeFileSync(filePath, initial);
27
+ }
28
+ catch {
29
+ return { ok: false, content: initial, cancelled: false };
30
+ }
31
+ const parts = editorRaw.split(/\s+/).filter(Boolean);
32
+ const cmd = parts[0] ?? "vim";
33
+ const baseArgs = parts.slice(1);
34
+ const needsWait = GUI_EDITORS_NEEDING_WAIT.has(path.basename(cmd)) &&
35
+ !baseArgs.includes("--wait") &&
36
+ !baseArgs.includes("-w");
37
+ const args = [...baseArgs, ...(needsWait ? ["--wait"] : []), filePath];
38
+ const wasRaw = process.stdin.isTTY ? process.stdin.isRaw : false;
39
+ if (process.stdin.isTTY && process.stdin.setRawMode) {
40
+ try {
41
+ process.stdin.setRawMode(false);
42
+ }
43
+ catch { }
44
+ }
45
+ const result = spawnSync(cmd, args, { stdio: "inherit" });
46
+ if (process.stdin.isTTY && process.stdin.setRawMode) {
47
+ try {
48
+ process.stdin.setRawMode(wasRaw);
49
+ }
50
+ catch { }
51
+ }
52
+ if (result.error || result.status !== 0) {
53
+ try {
54
+ fs.unlinkSync(filePath);
55
+ }
56
+ catch { }
57
+ return { ok: false, content: initial, cancelled: false };
58
+ }
59
+ let content;
60
+ try {
61
+ content = fs.readFileSync(filePath, "utf-8");
62
+ }
63
+ catch {
64
+ return { ok: false, content: initial, cancelled: false };
65
+ }
66
+ try {
67
+ fs.unlinkSync(filePath);
68
+ }
69
+ catch { }
70
+ if (content.trim().length === 0) {
71
+ return { ok: true, content: "", cancelled: true };
72
+ }
73
+ return { ok: true, content, cancelled: false };
74
+ }
package/dist/lib/git.d.ts CHANGED
@@ -257,11 +257,13 @@ export declare function getDiffContent(baseBranch: string): string | null;
257
257
  */
258
258
  export declare function readSessionState(repoRoot: string, ticketId: string): SessionState | null;
259
259
  /**
260
- * Check if a claude process is running in a tmux window for the given ticket.
261
- * Windows are named after ticket IDs (possibly with suffixes like " !" or " ~").
262
- * Gets the pane PID and walks the process tree looking for a "claude" process.
260
+ * Check if a session for the given ticket is still alive in the active multiplexer.
261
+ * Delegates to the configured multiplexer (tmux: pane_pid + pgrep claude;
262
+ * cmux: workspace lookup; none: always false). Callers should also consult the
263
+ * .santree/session-states/<ticketId>.json file — that's the authoritative signal
264
+ * written by Claude Code hooks, while this is the "is the terminal still up" backstop.
263
265
  */
264
- export declare function isSessionAliveInTmux(ticketId: string): boolean;
266
+ export declare function isSessionAlive(ticketId: string): boolean;
265
267
  /**
266
268
  * Delete the session state file for a given ticket.
267
269
  */
package/dist/lib/git.js CHANGED
@@ -3,6 +3,7 @@ import { promisify } from "util";
3
3
  import * as path from "path";
4
4
  import * as fs from "fs";
5
5
  import { run, runAsync } from "./exec.js";
6
+ import { getMultiplexer } from "./multiplexer/index.js";
6
7
  const execAsync = promisify(exec);
7
8
  /**
8
9
  * Find the toplevel directory of the current git repository.
@@ -610,40 +611,14 @@ export function readSessionState(repoRoot, ticketId) {
610
611
  }
611
612
  }
612
613
  /**
613
- * Check if a claude process is running in a tmux window for the given ticket.
614
- * Windows are named after ticket IDs (possibly with suffixes like " !" or " ~").
615
- * Gets the pane PID and walks the process tree looking for a "claude" process.
614
+ * Check if a session for the given ticket is still alive in the active multiplexer.
615
+ * Delegates to the configured multiplexer (tmux: pane_pid + pgrep claude;
616
+ * cmux: workspace lookup; none: always false). Callers should also consult the
617
+ * .santree/session-states/<ticketId>.json file — that's the authoritative signal
618
+ * written by Claude Code hooks, while this is the "is the terminal still up" backstop.
616
619
  */
617
- export function isSessionAliveInTmux(ticketId) {
618
- try {
619
- const output = execSync('tmux list-windows -F "#{window_name}\t#{pane_pid}"', {
620
- encoding: "utf-8",
621
- stdio: ["pipe", "pipe", "ignore"],
622
- }).trim();
623
- for (const line of output.split("\n")) {
624
- const [name, pidStr] = line.split("\t");
625
- if (!name?.startsWith(ticketId))
626
- continue;
627
- if (!pidStr)
628
- return false;
629
- // Check if any descendant of the shell PID is a claude process
630
- try {
631
- const ps = execSync(`pgrep -P ${pidStr} -a`, {
632
- encoding: "utf-8",
633
- stdio: ["pipe", "pipe", "ignore"],
634
- }).trim();
635
- return ps.split("\n").some((proc) => proc.includes("claude"));
636
- }
637
- catch {
638
- // pgrep exits 1 when no matches — shell has no children
639
- return false;
640
- }
641
- }
642
- }
643
- catch {
644
- // tmux not available or not in a tmux session
645
- }
646
- return false;
620
+ export function isSessionAlive(ticketId) {
621
+ return getMultiplexer().isSessionAlive(ticketId);
647
622
  }
648
623
  /**
649
624
  * Delete the session state file for a given ticket.
@@ -0,0 +1,2 @@
1
+ import type { Multiplexer } from "./types.js";
2
+ export declare const cmuxMultiplexer: Multiplexer;
@@ -0,0 +1,97 @@
1
+ import { execSync } from "child_process";
2
+ import { shellEscape } from "./types.js";
3
+ const CMUX_TIMEOUT_MS = 2000;
4
+ function cmuxRun(cmd) {
5
+ try {
6
+ const stdout = execSync(cmd, {
7
+ encoding: "utf-8",
8
+ stdio: ["pipe", "pipe", "ignore"],
9
+ timeout: CMUX_TIMEOUT_MS,
10
+ });
11
+ return { ok: true, stdout };
12
+ }
13
+ catch {
14
+ return { ok: false };
15
+ }
16
+ }
17
+ function findWorkspaceByTitle(title) {
18
+ // `--json` is a global flag and must precede the subcommand.
19
+ const result = cmuxRun("cmux --json list-workspaces");
20
+ if (!result.ok)
21
+ return null;
22
+ try {
23
+ const parsed = JSON.parse(result.stdout);
24
+ const items = parsed.workspaces ?? [];
25
+ return items.find((w) => w.title === title) ?? null;
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ }
31
+ export const cmuxMultiplexer = {
32
+ kind: "cmux",
33
+ isActive() {
34
+ return !!process.env["CMUX_SURFACE_ID"];
35
+ },
36
+ async createWindow({ name, cwd, command }) {
37
+ // `new-workspace` accepts --name, --cwd, --command in a single atomic call.
38
+ // `--command` sends "<text>\n" to the new surface after creation. cmux #1472 means
39
+ // programmatically created workspaces have dead PTYs, so the command may not actually
40
+ // execute — but the workspace + name are created, which is the visible win.
41
+ const parts = [`cmux new-workspace --name ${shellEscape(name)} --cwd ${shellEscape(cwd)}`];
42
+ if (command)
43
+ parts.push(`--command ${shellEscape(command)}`);
44
+ const created = cmuxRun(parts.join(" "));
45
+ if (!created.ok) {
46
+ return { ok: false, reason: "failed", message: "cmux new-workspace failed" };
47
+ }
48
+ return { ok: true };
49
+ },
50
+ async selectWindow(name) {
51
+ const ws = findWorkspaceByTitle(name);
52
+ if (!ws?.ref) {
53
+ return { ok: false, reason: "failed", message: `no cmux workspace named ${name}` };
54
+ }
55
+ const result = cmuxRun(`cmux select-workspace --workspace ${shellEscape(ws.ref)}`);
56
+ return result.ok ? { ok: true } : { ok: false, reason: "failed" };
57
+ },
58
+ renameWindow(currentName, newName) {
59
+ // `workspace-action --action rename --title <text>` defaults to the caller's
60
+ // workspace via $CMUX_WORKSPACE_ID. When `currentName` is provided we look up
61
+ // that specific workspace's ref instead.
62
+ let target = "";
63
+ if (currentName) {
64
+ const ws = findWorkspaceByTitle(currentName);
65
+ if (!ws?.ref) {
66
+ return { ok: false, reason: "failed", message: "cmux workspace not found" };
67
+ }
68
+ target = ` --workspace ${shellEscape(ws.ref)}`;
69
+ }
70
+ const result = cmuxRun(`cmux workspace-action --action rename --title ${shellEscape(newName)}${target}`);
71
+ return result.ok ? { ok: true } : { ok: false, reason: "failed" };
72
+ },
73
+ sendCommand(_name, _command) {
74
+ // Blocked by manaflow-ai/cmux#1472 — programmatically created workspaces have
75
+ // dead PTYs, so post-creation `cmux send` / `send-key` silently drop input.
76
+ // Initial command-on-create works via `new-workspace --command`; this path is for
77
+ // follow-up sends to an existing workspace, which doesn't.
78
+ return {
79
+ ok: false,
80
+ reason: "unsupported",
81
+ message: "blocked by manaflow-ai/cmux#1472",
82
+ };
83
+ },
84
+ isSessionAlive(ticketId) {
85
+ const result = cmuxRun("cmux --json list-workspaces");
86
+ if (!result.ok)
87
+ return false;
88
+ try {
89
+ const parsed = JSON.parse(result.stdout);
90
+ const items = parsed.workspaces ?? [];
91
+ return items.some((w) => typeof w.title === "string" && w.title.startsWith(ticketId));
92
+ }
93
+ catch {
94
+ return false;
95
+ }
96
+ },
97
+ };
@@ -0,0 +1,4 @@
1
+ import type { Multiplexer, MultiplexerKind } from "./types.js";
2
+ export type { CreateWindowOpts, Multiplexer, MultiplexerKind, SessionResult } from "./types.js";
3
+ export declare function getMultiplexer(): Multiplexer;
4
+ export declare function getMultiplexerKind(): MultiplexerKind;
@@ -0,0 +1,20 @@
1
+ import { cmuxMultiplexer } from "./cmux.js";
2
+ import { noneMultiplexer } from "./none.js";
3
+ import { tmuxMultiplexer } from "./tmux.js";
4
+ export function getMultiplexer() {
5
+ const explicit = process.env["SANTREE_MULTIPLEXER"]?.toLowerCase();
6
+ if (explicit === "tmux")
7
+ return tmuxMultiplexer;
8
+ if (explicit === "cmux")
9
+ return cmuxMultiplexer;
10
+ if (explicit === "none")
11
+ return noneMultiplexer;
12
+ if (process.env["TMUX"])
13
+ return tmuxMultiplexer;
14
+ if (process.env["CMUX_SURFACE_ID"])
15
+ return cmuxMultiplexer;
16
+ return noneMultiplexer;
17
+ }
18
+ export function getMultiplexerKind() {
19
+ return getMultiplexer().kind;
20
+ }
@@ -0,0 +1,2 @@
1
+ import type { Multiplexer } from "./types.js";
2
+ export declare const noneMultiplexer: Multiplexer;
@@ -0,0 +1,22 @@
1
+ const NOT_ACTIVE = { ok: false, reason: "not-active" };
2
+ export const noneMultiplexer = {
3
+ kind: "none",
4
+ isActive() {
5
+ return false;
6
+ },
7
+ async createWindow() {
8
+ return NOT_ACTIVE;
9
+ },
10
+ async selectWindow() {
11
+ return NOT_ACTIVE;
12
+ },
13
+ renameWindow() {
14
+ return NOT_ACTIVE;
15
+ },
16
+ sendCommand() {
17
+ return NOT_ACTIVE;
18
+ },
19
+ isSessionAlive() {
20
+ return false;
21
+ },
22
+ };
@@ -0,0 +1,2 @@
1
+ import type { Multiplexer } from "./types.js";
2
+ export declare const tmuxMultiplexer: Multiplexer;
@@ -0,0 +1,82 @@
1
+ import { execSync } from "child_process";
2
+ import { shellEscape } from "./types.js";
3
+ function tmuxSync(cmd) {
4
+ try {
5
+ execSync(cmd, { stdio: "ignore" });
6
+ return true;
7
+ }
8
+ catch {
9
+ return false;
10
+ }
11
+ }
12
+ export const tmuxMultiplexer = {
13
+ kind: "tmux",
14
+ isActive() {
15
+ return !!process.env["TMUX"];
16
+ },
17
+ async createWindow({ name, cwd, command }) {
18
+ if (!this.isActive())
19
+ return { ok: false, reason: "not-active" };
20
+ const ok = tmuxSync(`tmux new-window -n ${shellEscape(name)} -c ${shellEscape(cwd)}`);
21
+ if (!ok)
22
+ return { ok: false, reason: "failed", message: "tmux new-window failed" };
23
+ if (command) {
24
+ // Brief race guard: tmux occasionally drops send-keys if it arrives before the
25
+ // window's shell is up. The dashboard has used this for years.
26
+ await new Promise((r) => setTimeout(r, 100));
27
+ const sent = tmuxSync(`tmux send-keys -t ${shellEscape(name)} ${shellEscape(command)} Enter`);
28
+ if (!sent)
29
+ return { ok: false, reason: "failed", message: "tmux send-keys failed" };
30
+ }
31
+ return { ok: true };
32
+ },
33
+ async selectWindow(name) {
34
+ if (!this.isActive())
35
+ return { ok: false, reason: "not-active" };
36
+ const ok = tmuxSync(`tmux select-window -t ${shellEscape(name)}`);
37
+ return ok ? { ok: true } : { ok: false, reason: "failed" };
38
+ },
39
+ renameWindow(_currentName, newName) {
40
+ if (!this.isActive())
41
+ return { ok: false, reason: "not-active" };
42
+ // tmux rename-window operates on the current window when no -t is given, which
43
+ // matches every existing call site in santree.
44
+ const ok = tmuxSync(`tmux rename-window ${shellEscape(newName)}`);
45
+ return ok ? { ok: true } : { ok: false, reason: "failed" };
46
+ },
47
+ sendCommand(name, command) {
48
+ if (!this.isActive())
49
+ return { ok: false, reason: "not-active" };
50
+ const ok = tmuxSync(`tmux send-keys -t ${shellEscape(name)} ${shellEscape(command)} Enter`);
51
+ return ok ? { ok: true } : { ok: false, reason: "failed" };
52
+ },
53
+ isSessionAlive(ticketId) {
54
+ try {
55
+ const output = execSync('tmux list-windows -F "#{window_name}\t#{pane_pid}"', {
56
+ encoding: "utf-8",
57
+ stdio: ["pipe", "pipe", "ignore"],
58
+ }).trim();
59
+ for (const line of output.split("\n")) {
60
+ const [name, pidStr] = line.split("\t");
61
+ if (!name?.startsWith(ticketId))
62
+ continue;
63
+ if (!pidStr)
64
+ return false;
65
+ try {
66
+ const ps = execSync(`pgrep -P ${pidStr} -a`, {
67
+ encoding: "utf-8",
68
+ stdio: ["pipe", "pipe", "ignore"],
69
+ }).trim();
70
+ return ps.split("\n").some((proc) => proc.includes("claude"));
71
+ }
72
+ catch {
73
+ return false;
74
+ }
75
+ }
76
+ }
77
+ catch {
78
+ // tmux not available
79
+ }
80
+ return false;
81
+ },
82
+ };
@@ -0,0 +1,23 @@
1
+ export type MultiplexerKind = "tmux" | "cmux" | "none";
2
+ export type SessionResult = {
3
+ ok: true;
4
+ } | {
5
+ ok: false;
6
+ reason: "not-active" | "unsupported" | "failed";
7
+ message?: string;
8
+ };
9
+ export interface CreateWindowOpts {
10
+ name: string;
11
+ cwd: string;
12
+ command?: string;
13
+ }
14
+ export interface Multiplexer {
15
+ readonly kind: MultiplexerKind;
16
+ isActive(): boolean;
17
+ createWindow(opts: CreateWindowOpts): Promise<SessionResult>;
18
+ selectWindow(name: string): Promise<SessionResult>;
19
+ renameWindow(currentName: string, newName: string): SessionResult;
20
+ sendCommand(name: string, command: string): SessionResult;
21
+ isSessionAlive(ticketId: string): boolean;
22
+ }
23
+ export declare function shellEscape(s: string): string;
@@ -0,0 +1,3 @@
1
+ export function shellEscape(s) {
2
+ return `'${s.replace(/'/g, `'\\''`)}'`;
3
+ }
@@ -1,6 +1,7 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
- import { execSync, spawn } from "child_process";
3
+ import { spawn } from "child_process";
4
+ import { getMultiplexer } from "./multiplexer/index.js";
4
5
  export function readStdin() {
5
6
  try {
6
7
  return fs.readFileSync(0, "utf-8");
@@ -22,7 +23,8 @@ export function extractRepoAndTicket(cwd) {
22
23
  return { repoRoot, ticketId };
23
24
  }
24
25
  export function renameTmuxWindow(ticketId, state) {
25
- if (!process.env.TMUX)
26
+ const mux = getMultiplexer();
27
+ if (!mux.isActive())
26
28
  return;
27
29
  let name;
28
30
  switch (state) {
@@ -36,12 +38,7 @@ export function renameTmuxWindow(ticketId, state) {
36
38
  name = ticketId;
37
39
  break;
38
40
  }
39
- try {
40
- execSync(`tmux rename-window "${name}"`, { stdio: "ignore" });
41
- }
42
- catch {
43
- // Ignore tmux errors
44
- }
41
+ mux.renameWindow("", name);
45
42
  }
46
43
  export function runHookScript(repoRoot, state, env) {
47
44
  const script = path.join(repoRoot, ".santree", "hooks", `on-${state}.sh`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "santree",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Git worktree manager",
5
5
  "license": "MIT",
6
6
  "author": "Santiago Toscanini",
@@ -1,22 +1,45 @@
1
- # Santree Shell Integration for Zsh
2
- # ==================================
1
+ # Santree Shell Integration for Zsh — self-caching bootstrap
2
+ # ===========================================================
3
3
  #
4
- # This script provides a shell wrapper around the santree CLI to enable:
5
- # 1. Automatic directory switching after `worktree create` and `worktree switch` commands
6
- # 2. Automatic recovery when the current worktree directory is deleted
4
+ # This output is produced by `santree helpers shell-init zsh`. The santree CLI
5
+ # cold-starts in several seconds (Node + Pastel), so eval'ing it on every shell
6
+ # launch is too slow. To avoid that, the bootstrap below writes the rendered
7
+ # integration body to a cache file once, then sources the cache. Subsequent
8
+ # shells can source the cache file directly — bypassing the santree CLI
9
+ # entirely — and the cache self-invalidates whenever the santree binary is
10
+ # upgraded (see the self-validation header at the top of the cache content).
7
11
  #
8
- # Installation:
9
- # Add to your .zshrc: eval "$(santree helpers shell-init zsh)"
12
+ # .zshrc usage (one-liner with first-run fallback):
13
+ # _SI=${XDG_CACHE_HOME:-$HOME/.cache}/santree/init-zsh.zsh
14
+ # [[ -f $_SI ]] && source $_SI || eval "$(santree helpers shell-init zsh)"
10
15
  #
11
- # How it works:
12
- # -------------
13
- # Since child processes cannot change the parent shell's directory, the CLI
14
- # outputs special markers (SANTREE_CD:path) that this wrapper intercepts
15
- # to perform the actual `cd` command in the current shell.
16
+ # Behavior:
17
+ # - First shell after install: cache miss → runs santree (slow), writes cache.
18
+ # - Subsequent shells: source cache directly (fast, no santree spawn).
19
+ # - After `npm i -g santree` upgrade: cache mtime older than new binary,
20
+ # self-validation triggers a one-time regeneration in that shell.
21
+
22
+ _santree_cache="${SANTREE_CACHE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/santree}/init-zsh.zsh"
23
+ mkdir -p "${_santree_cache:h}"
24
+
25
+ # Write the integration body to the cache file. Single-quoted heredoc
26
+ # delimiter ('SANTREE_INIT_BODY_EOF__') prevents parameter expansion of the
27
+ # body — the variables and functions are evaluated only when the cache file
28
+ # is sourced, not during this write.
29
+ cat > "$_santree_cache" <<'SANTREE_INIT_BODY_EOF__'
30
+ # Santree Shell Integration for Zsh
31
+ # ==================================
32
+ # AUTO-GENERATED CACHE — do not edit. Regenerate with:
33
+ # eval "$(santree helpers shell-init zsh)"
16
34
  #
17
- # The wrapper also handles the case where you're in a worktree directory
18
- # that gets deleted (e.g., after `santree worktree clean` or `santree worktree remove`),
19
- # automatically returning you to the main repository or home directory.
35
+ # Self-validation: if the santree binary on $PATH is newer than this cache
36
+ # file, fall back to the slow path: re-run santree, which overwrites this
37
+ # cache and re-sources the fresh integration. `return` short-circuits the
38
+ # (now-stale) body below so its definitions don't get loaded.
39
+ if [[ ${commands[santree]:-} -nt ${(%):-%x} ]]; then
40
+ eval "$(command santree helpers shell-init zsh)"
41
+ return
42
+ fi
20
43
 
21
44
  # Export marker so `santree doctor` can verify shell integration is loaded
22
45
  export SANTREE_SHELL_INTEGRATION=1
@@ -172,3 +195,10 @@ if (( $+functions[compdef] )); then
172
195
  compdef _santree santree
173
196
  compdef _santree st
174
197
  fi
198
+ SANTREE_INIT_BODY_EOF__
199
+
200
+ # Source the cache we just wrote so this shell gets the integration immediately.
201
+ # Future shells can skip the santree CLI and source the cache directly via the
202
+ # .zshrc one-liner shown in the comment block at the top of this output.
203
+ source "$_santree_cache"
204
+ unset _santree_cache