santree 0.2.6 → 0.2.8

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.
package/README.md CHANGED
@@ -121,10 +121,16 @@ With the `stw` alias: `stw create`, `stw list`, `stw switch`, `stw work`, `stw c
121
121
 
122
122
  ### Helpers (`santree helpers`)
123
123
 
124
- | Command | Description |
125
- | ---------------------------- | --------------------------------- |
126
- | `santree helpers shell-init` | Output shell integration script |
127
- | `santree helpers statusline` | Custom statusline for Claude Code |
124
+ | Command | Description |
125
+ | ---------------------------------------------- | ------------------------------------------------ |
126
+ | `santree helpers shell-init` | Output shell integration script |
127
+ | `santree helpers statusline` | Custom statusline for Claude Code |
128
+ | `santree helpers session-signal notification` | Signal waiting state (Notification hook) |
129
+ | `santree helpers session-signal stop` | Signal idle state (Stop hook) |
130
+ | `santree helpers session-signal prompt` | Signal active state (UserPromptSubmit hook) |
131
+ | `santree helpers session-signal end` | Signal exited state (SessionEnd hook) |
132
+ | `santree helpers session-signal install` | Auto-install session-signal hooks in Claude Code |
133
+ | `santree helpers session-signal install --dry` | Print the hooks JSON without writing |
128
134
 
129
135
  ### Top-level
130
136
 
@@ -252,6 +258,65 @@ Enable [Remote Control](https://code.claude.com/docs/en/remote-control) to conti
252
258
 
253
259
  Enable it for all sessions by running `/config` inside Claude Code and setting **Enable Remote Control for all sessions** to `true`. This writes `remoteControlAtStartup: true` to `~/.claude.json`. Run `santree doctor` to verify.
254
260
 
261
+ ### Session State Signaling (Optional)
262
+
263
+ Surfaces the current Claude Code session state in the dashboard, statusline, and tmux window names. Shows whether a session is actively working, waiting for permission approval, idle, or exited.
264
+
265
+ **States:**
266
+ | State | Meaning |
267
+ |-------|---------|
268
+ | `active` | User submitted a prompt, Claude is working |
269
+ | `waiting` | Claude needs permission approval |
270
+ | `idle` | Claude finished and is waiting for next prompt |
271
+ | `exited` | Session ended |
272
+
273
+ **Install:**
274
+
275
+ ```bash
276
+ santree helpers session-signal install
277
+ ```
278
+
279
+ This adds hooks for `Notification`, `Stop`, `UserPromptSubmit`, and `SessionEnd` to `~/.claude/settings.json`. Existing hooks are preserved.
280
+
281
+ To preview the JSON without writing: `santree helpers session-signal install --dry`
282
+
283
+ Verify with `santree doctor` — look for the "Session Signal Hooks" row under Claude Code.
284
+
285
+ ### Session Hooks (Optional)
286
+
287
+ Run custom scripts when Claude's session state changes. Create executable scripts in `.santree/hooks/`:
288
+
289
+ ```
290
+ .santree/hooks/
291
+ on-waiting.sh # Runs when session needs permission approval
292
+ on-active.sh # Runs when user submits a new prompt
293
+ on-idle.sh # Runs when session finishes and waits for next prompt
294
+ on-exited.sh # Runs when session ends
295
+ ```
296
+
297
+ Each script receives these environment variables:
298
+
299
+ | Variable | Description |
300
+ |----------|-------------|
301
+ | `SANTREE_TICKET_ID` | e.g. `TEAM-123` |
302
+ | `SANTREE_SESSION_STATE` | `waiting`, `active`, `idle`, or `exited` |
303
+ | `SANTREE_SESSION_ID` | The Claude session ID |
304
+ | `SANTREE_WORKTREE_PATH` | Absolute path to the worktree |
305
+ | `SANTREE_REPO_ROOT` | Absolute path to the main repo |
306
+ | `SANTREE_MESSAGE` | Notification message (only for `waiting`) |
307
+
308
+ Scripts are optional — only executed if they exist and are executable. They run fire-and-forget with a 5-second timeout.
309
+
310
+ **Example** — log when Claude is waiting for approval:
311
+
312
+ ```bash
313
+ #!/bin/bash
314
+ # .santree/hooks/on-waiting.sh
315
+ echo "$(date): $SANTREE_TICKET_ID waiting — $SANTREE_MESSAGE" >> /tmp/santree-hooks.log
316
+ ```
317
+
318
+ Make it executable: `chmod +x .santree/hooks/on-waiting.sh`
319
+
255
320
  ---
256
321
 
257
322
  ## Command Options
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import Spinner from "ink-spinner";
4
4
  import { useEffect, useState } from "react";
@@ -56,7 +56,8 @@ async function checkTool(name, description, required, versionCommand, hint) {
56
56
  };
57
57
  }
58
58
  /**
59
- * Checks GitHub CLI auth status using JSON output.
59
+ * Checks GitHub CLI auth status.
60
+ * Uses `gh api user` which works across all gh versions.
60
61
  */
61
62
  async function checkGhAuth() {
62
63
  const path = await getPath("gh");
@@ -70,22 +71,8 @@ async function checkGhAuth() {
70
71
  };
71
72
  }
72
73
  const version = await tryExec("gh --version | head -1");
73
- const authJson = await tryExec("gh auth status --json hosts 2>/dev/null");
74
- let authStatus;
75
- if (authJson) {
76
- try {
77
- const auth = JSON.parse(authJson);
78
- const githubHosts = auth.hosts?.["github.com"];
79
- const activeAccount = githubHosts?.find((h) => h.active);
80
- if (activeAccount?.login) {
81
- authStatus = `Authenticated as ${activeAccount.login}`;
82
- }
83
- }
84
- catch {
85
- // JSON parse failed, auth might not be configured
86
- }
87
- }
88
- if (!authStatus) {
74
+ const login = await tryExec("gh api user --jq .login 2>/dev/null");
75
+ if (!login) {
89
76
  return {
90
77
  name: "gh",
91
78
  description: "GitHub CLI for PR operations",
@@ -103,7 +90,7 @@ async function checkGhAuth() {
103
90
  installed: true,
104
91
  version: version || "unknown",
105
92
  path,
106
- authStatus,
93
+ authStatus: `Authenticated as ${login}`,
107
94
  };
108
95
  }
109
96
  /**
@@ -207,6 +194,79 @@ async function checkStatusline() {
207
194
  hint,
208
195
  };
209
196
  }
197
+ /**
198
+ * Checks if session-signal hooks are configured in ~/.claude/settings.json.
199
+ * Looks for hooks on Notification, Stop, UserPromptSubmit, and SessionEnd
200
+ * that run "santree helpers session-signal".
201
+ */
202
+ function checkSessionSignalHooks() {
203
+ const home = process.env.HOME || "";
204
+ const claudeSettingsPath = path.join(home, ".claude", "settings.json");
205
+ const requiredEvents = ["Notification", "Stop", "UserPromptSubmit", "SessionEnd"];
206
+ const missingHooks = [];
207
+ try {
208
+ if (fs.existsSync(claudeSettingsPath)) {
209
+ const content = fs.readFileSync(claudeSettingsPath, "utf-8");
210
+ const settings = JSON.parse(content);
211
+ const hooks = settings.hooks || {};
212
+ for (const event of requiredEvents) {
213
+ const eventHooks = hooks[event];
214
+ if (!Array.isArray(eventHooks)) {
215
+ missingHooks.push(event);
216
+ continue;
217
+ }
218
+ // Check if any hook entry has a nested hook command containing session-signal
219
+ const found = eventHooks.some((entry) => {
220
+ const innerHooks = entry.hooks || [];
221
+ return innerHooks.some((h) => typeof h.command === "string" && h.command.includes("session-signal"));
222
+ });
223
+ if (!found)
224
+ missingHooks.push(event);
225
+ }
226
+ }
227
+ else {
228
+ missingHooks.push(...requiredEvents);
229
+ }
230
+ }
231
+ catch {
232
+ missingHooks.push(...requiredEvents);
233
+ }
234
+ // Check hook script files in .santree/hooks/
235
+ const hookScripts = [];
236
+ const mainRepoRoot = findMainRepoRoot();
237
+ let hooksDir = null;
238
+ if (mainRepoRoot) {
239
+ hooksDir = path.join(mainRepoRoot, ".santree", "hooks");
240
+ const scriptNames = ["on-waiting.sh", "on-active.sh", "on-idle.sh", "on-exited.sh"];
241
+ for (const name of scriptNames) {
242
+ const scriptPath = path.join(hooksDir, name);
243
+ const exists = fs.existsSync(scriptPath);
244
+ let executable = false;
245
+ if (exists) {
246
+ try {
247
+ fs.accessSync(scriptPath, fs.constants.X_OK);
248
+ executable = true;
249
+ }
250
+ catch {
251
+ // not executable
252
+ }
253
+ }
254
+ if (exists) {
255
+ hookScripts.push({ name, exists, executable });
256
+ }
257
+ }
258
+ }
259
+ if (missingHooks.length === 0) {
260
+ return { configured: true, missingHooks: [], hookScripts, hooksDir };
261
+ }
262
+ return {
263
+ configured: false,
264
+ missingHooks,
265
+ hookScripts,
266
+ hooksDir,
267
+ hint: `Missing: ${missingHooks.join(", ")}. Run: santree helpers session-signal install`,
268
+ };
269
+ }
210
270
  /**
211
271
  * Checks if a path is gitignored (via .gitignore or .git/info/exclude).
212
272
  */
@@ -301,6 +361,15 @@ function StatuslineRow({ status }) {
301
361
  function RemoteControlRow({ status }) {
302
362
  return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(StatusIcon, { ok: status.enabled, required: false }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "Remote Control" }), _jsx(Text, { dimColor: true, children: " - Continue sessions from any device" }), _jsx(Text, { dimColor: true, children: " (optional)" })] }), _jsxs(Box, { marginLeft: 2, flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["Enabled: ", status.enabled ? "yes" : "no"] }), status.hint && _jsxs(Text, { color: "yellow", children: ["\u21B3 ", status.hint] })] })] }));
303
363
  }
364
+ function SessionSignalRow({ status }) {
365
+ const allScriptNames = ["on-waiting.sh", "on-active.sh", "on-idle.sh", "on-exited.sh"];
366
+ const existingNames = new Set(status.hookScripts.map((s) => s.name));
367
+ const missingScripts = allScriptNames.filter((n) => !existingNames.has(n));
368
+ const nonExecutable = status.hookScripts.filter((s) => !s.executable);
369
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(StatusIcon, { ok: status.configured && nonExecutable.length === 0, required: false }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "Session Signal Hooks" }), _jsx(Text, { dimColor: true, children: " - Surface session state in dashboard/tmux" }), _jsx(Text, { dimColor: true, children: " (optional)" })] }), _jsxs(Box, { marginLeft: 2, flexDirection: "column", children: [status.configured ? (_jsx(Text, { dimColor: true, children: "All hooks configured" })) : (_jsxs(_Fragment, { children: [_jsxs(Text, { dimColor: true, children: ["Missing: ", status.missingHooks.join(", ")] }), status.hint && _jsxs(Text, { color: "yellow", children: ["\u21B3 ", status.hint] })] })), status.hooksDir && (_jsxs(Text, { dimColor: true, children: ["Hook scripts:", " ", status.hookScripts.length > 0
370
+ ? status.hookScripts.map((s) => s.name).join(", ")
371
+ : "none"] })), status.hooksDir && missingScripts.length > 0 && (_jsxs(Text, { color: "yellow", children: ["\u21B3 Create in ", status.hooksDir, ": ", missingScripts.join(", ")] })), nonExecutable.map((s) => (_jsxs(Text, { color: "yellow", children: ["\u21B3 ", s.name, " is not executable. Run: chmod +x .santree/hooks/", s.name] }, s.name)))] })] }));
372
+ }
304
373
  function SantreeSetupRow({ status }) {
305
374
  const isOk = status.santreeFolderExists &&
306
375
  status.initShExists &&
@@ -322,6 +391,7 @@ export default function Doctor() {
322
391
  const [shellStatus, setShellStatus] = useState(null);
323
392
  const [remoteControl, setRemoteControl] = useState(null);
324
393
  const [statusline, setStatusline] = useState(null);
394
+ const [sessionSignal, setSessionSignal] = useState(null);
325
395
  const [santreeSetup, setSantreeSetup] = useState(null);
326
396
  const [loading, setLoading] = useState(true);
327
397
  useEffect(() => {
@@ -359,6 +429,7 @@ export default function Doctor() {
359
429
  setShellStatus(checkShellIntegration());
360
430
  setRemoteControl(checkRemoteControl());
361
431
  setStatusline(statuslineResult);
432
+ setSessionSignal(checkSessionSignalHooks());
362
433
  setSantreeSetup(checkSantreeSetup());
363
434
  setLoading(false);
364
435
  }
@@ -371,5 +442,5 @@ export default function Doctor() {
371
442
  const optionalMissing = tools.filter((t) => !t.required && !t.installed);
372
443
  const linearOk = linear?.authenticated && linear?.tokenValid && linear?.repoLinked;
373
444
  const allRequired = requiredMissing.length === 0 && linearOk && shellStatus?.configured;
374
- return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "Santree Doctor" }), _jsxs(Text, { dimColor: true, children: [" v", version] })] }), _jsx(Box, { marginBottom: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "CLI Tools" }) }), tools.map((tool) => (_jsx(ToolRow, { tool: tool }, tool.name))), _jsx(Box, { marginBottom: 1, marginTop: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "Integrations" }) }), linear && _jsx(LinearRow, { linear: linear }), shellStatus && _jsx(ShellRow, { configured: shellStatus.configured, shell: shellStatus.shell }), santreeSetup && _jsx(SantreeSetupRow, { status: santreeSetup }), _jsx(Box, { marginBottom: 1, marginTop: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "Claude Code" }) }), remoteControl && _jsx(RemoteControlRow, { status: remoteControl }), statusline && _jsx(StatuslineRow, { status: statusline }), _jsx(Box, { marginTop: 1, borderStyle: "single", borderColor: allRequired ? "green" : "yellow", paddingX: 2, children: allRequired ? (_jsx(Text, { color: "green", children: "All requirements satisfied! Santree is ready to use." })) : (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: [requiredMissing.length + (linearOk ? 0 : 1) + (shellStatus?.configured ? 0 : 1), " ", "required item(s) need attention"] }), optionalMissing.length > 0 && (_jsxs(Text, { dimColor: true, children: [optionalMissing.length, " optional item(s) not installed"] }))] })) })] }));
445
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "Santree Doctor" }), _jsxs(Text, { dimColor: true, children: [" v", version] })] }), _jsx(Box, { marginBottom: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "CLI Tools" }) }), tools.map((tool) => (_jsx(ToolRow, { tool: tool }, tool.name))), _jsx(Box, { marginBottom: 1, marginTop: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "Integrations" }) }), linear && _jsx(LinearRow, { linear: linear }), shellStatus && _jsx(ShellRow, { configured: shellStatus.configured, shell: shellStatus.shell }), santreeSetup && _jsx(SantreeSetupRow, { status: santreeSetup }), _jsx(Box, { marginBottom: 1, marginTop: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "Claude Code" }) }), remoteControl && _jsx(RemoteControlRow, { status: remoteControl }), statusline && _jsx(StatuslineRow, { status: statusline }), sessionSignal && _jsx(SessionSignalRow, { status: sessionSignal }), _jsx(Box, { marginTop: 1, borderStyle: "single", borderColor: allRequired ? "green" : "yellow", paddingX: 2, children: allRequired ? (_jsx(Text, { color: "green", children: "All requirements satisfied! Santree is ready to use." })) : (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: [requiredMissing.length + (linearOk ? 0 : 1) + (shellStatus?.configured ? 0 : 1), " ", "required item(s) need attention"] }), optionalMissing.length > 0 && (_jsxs(Text, { dimColor: true, children: [optionalMissing.length, " optional item(s) not installed"] }))] })) })] }));
375
446
  }
@@ -0,0 +1,2 @@
1
+ export declare const description = "Signal exited state (SessionEnd hook)";
2
+ export default function End(): null;
@@ -0,0 +1,13 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { signalState } from "../../../lib/session-signal.js";
3
+ export const description = "Signal exited state (SessionEnd hook)";
4
+ export default function End() {
5
+ const hasRun = useRef(false);
6
+ useEffect(() => {
7
+ if (hasRun.current)
8
+ return;
9
+ hasRun.current = true;
10
+ signalState("exited");
11
+ }, []);
12
+ return null;
13
+ }
@@ -0,0 +1 @@
1
+ export declare const description = "Signal session state for Claude Code hooks";
@@ -0,0 +1 @@
1
+ export const description = "Signal session state for Claude Code hooks";
@@ -0,0 +1,8 @@
1
+ import { z } from "zod";
2
+ export declare const description = "Install session-signal hooks into Claude Code settings";
3
+ export declare const options: z.ZodObject<{
4
+ dry: z.ZodOptional<z.ZodBoolean>;
5
+ }, z.core.$strip>;
6
+ export default function Install({ options: opts }: {
7
+ options: z.infer<typeof options>;
8
+ }): null;
@@ -0,0 +1,24 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { z } from "zod";
3
+ import { getHooksJson, installHooks } from "../../../lib/session-signal.js";
4
+ export const description = "Install session-signal hooks into Claude Code settings";
5
+ export const options = z.object({
6
+ dry: z.boolean().optional().describe("Print the hooks JSON without writing"),
7
+ });
8
+ export default function Install({ options: opts }) {
9
+ const hasRun = useRef(false);
10
+ useEffect(() => {
11
+ if (hasRun.current)
12
+ return;
13
+ hasRun.current = true;
14
+ if (opts?.dry) {
15
+ const snippet = { hooks: getHooksJson() };
16
+ process.stdout.write(JSON.stringify(snippet, null, 2) + "\n");
17
+ process.exit(0);
18
+ }
19
+ const settingsPath = installHooks();
20
+ process.stdout.write(`Session-signal hooks installed in ${settingsPath}\n`);
21
+ process.exit(0);
22
+ }, []);
23
+ return null;
24
+ }
@@ -0,0 +1,2 @@
1
+ export declare const description = "Signal waiting state (Notification hook)";
2
+ export default function Notification(): null;
@@ -0,0 +1,13 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { signalState } from "../../../lib/session-signal.js";
3
+ export const description = "Signal waiting state (Notification hook)";
4
+ export default function Notification() {
5
+ const hasRun = useRef(false);
6
+ useEffect(() => {
7
+ if (hasRun.current)
8
+ return;
9
+ hasRun.current = true;
10
+ signalState("waiting");
11
+ }, []);
12
+ return null;
13
+ }
@@ -0,0 +1,2 @@
1
+ export declare const description = "Signal active state (UserPromptSubmit hook)";
2
+ export default function Prompt(): null;
@@ -0,0 +1,13 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { signalState } from "../../../lib/session-signal.js";
3
+ export const description = "Signal active state (UserPromptSubmit hook)";
4
+ export default function Prompt() {
5
+ const hasRun = useRef(false);
6
+ useEffect(() => {
7
+ if (hasRun.current)
8
+ return;
9
+ hasRun.current = true;
10
+ signalState("active");
11
+ }, []);
12
+ return null;
13
+ }
@@ -0,0 +1,2 @@
1
+ export declare const description = "Signal idle state (Stop hook)";
2
+ export default function Stop(): null;
@@ -0,0 +1,13 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { signalState } from "../../../lib/session-signal.js";
3
+ export const description = "Signal idle state (Stop hook)";
4
+ export default function Stop() {
5
+ const hasRun = useRef(false);
6
+ useEffect(() => {
7
+ if (hasRun.current)
8
+ return;
9
+ hasRun.current = true;
10
+ signalState("idle");
11
+ }, []);
12
+ return null;
13
+ }
@@ -83,6 +83,28 @@ function isWorktree(cwd) {
83
83
  function isSantreeWorktree(cwd) {
84
84
  return cwd.includes("/.santree/worktrees/");
85
85
  }
86
+ // Read session state from per-ticket state file
87
+ function readSessionStateFile(cwd) {
88
+ const marker = "/.santree/worktrees/";
89
+ const idx = cwd.indexOf(marker);
90
+ if (idx === -1)
91
+ return null;
92
+ const repoRoot = cwd.slice(0, idx);
93
+ const rest = cwd.slice(idx + marker.length);
94
+ const ticketId = rest.split("/")[0];
95
+ if (!ticketId)
96
+ return null;
97
+ const filePath = path.join(repoRoot, ".santree", "session-states", `${ticketId}.json`);
98
+ try {
99
+ const data = JSON.parse(fs.readFileSync(filePath, "utf-8"));
100
+ if (data.state === "exited")
101
+ return null;
102
+ return data.state;
103
+ }
104
+ catch {
105
+ return null;
106
+ }
107
+ }
86
108
  // Extract ticket ID from branch name (e.g., feature/TEAM-123-desc -> TEAM-123)
87
109
  function extractTicketId(branch) {
88
110
  const match = branch.match(/([a-zA-Z]+)-(\d+)/);
@@ -141,15 +163,23 @@ function buildSantreeStatusline(cwd, model, usedPercentage) {
141
163
  // Git changes
142
164
  const changes = getGitChanges(cwd);
143
165
  parts.push(formatChanges(changes));
166
+ // Session state
167
+ const sessState = readSessionStateFile(cwd);
168
+ if (sessState === "waiting") {
169
+ parts.push(`${c.red}WAITING${c.reset}`);
170
+ }
171
+ else if (sessState === "idle") {
172
+ parts.push(`${c.yellow}idle${c.reset}`);
173
+ }
144
174
  // Model
145
175
  if (model) {
146
176
  parts.push(`${c.blue}${model}${c.reset}`);
147
177
  }
148
- // Usable context % (accounting for 80% auto-compact threshold)
178
+ // Context usage %
149
179
  if (usedPercentage !== null) {
150
- const usable = Math.round(usedPercentage * 1.25);
151
- const color = usable >= 80 ? c.red : usable >= 60 ? c.yellow : c.green;
152
- parts.push(`${color}${usable}%${c.reset}`);
180
+ const used = Math.round(usedPercentage);
181
+ const color = used >= 80 ? c.red : used >= 60 ? c.yellow : c.green;
182
+ parts.push(`${color}${used}%${c.reset}`);
153
183
  }
154
184
  return parts.join(" | ");
155
185
  }
@@ -170,11 +200,11 @@ function buildGitStatusline(cwd, model, usedPercentage) {
170
200
  if (model) {
171
201
  parts.push(`${c.blue}${model}${c.reset}`);
172
202
  }
173
- // Usable context %
203
+ // Context usage %
174
204
  if (usedPercentage !== null) {
175
- const usable = Math.round(usedPercentage * 1.25);
176
- const color = usable >= 80 ? c.red : usable >= 60 ? c.yellow : c.green;
177
- parts.push(`${color}${usable}%${c.reset}`);
205
+ const used = Math.round(usedPercentage);
206
+ const color = used >= 80 ? c.red : used >= 60 ? c.yellow : c.green;
207
+ parts.push(`${color}${used}%${c.reset}`);
178
208
  }
179
209
  return parts.join(" | ");
180
210
  }
@@ -188,11 +218,11 @@ function buildPlainStatusline(cwd, model, usedPercentage) {
188
218
  if (model) {
189
219
  parts.push(`${c.blue}${model}${c.reset}`);
190
220
  }
191
- // Usable context %
221
+ // Context usage %
192
222
  if (usedPercentage !== null) {
193
- const usable = Math.round(usedPercentage * 1.25);
194
- const color = usable >= 80 ? c.red : usable >= 60 ? c.yellow : c.green;
195
- parts.push(`${color}${usable}%${c.reset}`);
223
+ const used = Math.round(usedPercentage);
224
+ const color = used >= 80 ? c.red : used >= 60 ? c.yellow : c.green;
225
+ parts.push(`${color}${used}%${c.reset}`);
196
226
  }
197
227
  return parts.join(" | ");
198
228
  }
@@ -163,6 +163,18 @@ export default function DetailPanel({ issue, scrollOffset, height, width, creati
163
163
  else {
164
164
  lines.push({ text: " session: none", color: "red" });
165
165
  }
166
+ if (worktree.sessionState === "waiting") {
167
+ const msg = worktree.sessionMessage
168
+ ? `NEEDS INPUT: ${worktree.sessionMessage}`
169
+ : "NEEDS INPUT";
170
+ lines.push({ text: ` ${msg}`, color: "red" });
171
+ }
172
+ else if (worktree.sessionState === "idle") {
173
+ lines.push({ text: " session idle (waiting for prompt)", color: "yellow" });
174
+ }
175
+ else if (worktree.sessionState === "active") {
176
+ lines.push({ text: " session active (working)", color: "green" });
177
+ }
166
178
  }
167
179
  else {
168
180
  lines.push({ text: " –", dim: true });
@@ -63,6 +63,13 @@ function sessionIndicator(wt, isCreating, isDeleting) {
63
63
  return { text: " creating", color: "yellow" };
64
64
  if (!wt)
65
65
  return { text: " -", color: "gray" };
66
+ // Session state takes priority over session ID
67
+ if (wt.sessionState === "waiting")
68
+ return { text: " waiting!", color: "red" };
69
+ if (wt.sessionState === "active")
70
+ return { text: " active", color: "green" };
71
+ if (wt.sessionState === "idle")
72
+ return { text: " idle", color: "yellow" };
66
73
  if (wt.sessionId)
67
74
  return { text: " " + wt.sessionId.slice(0, 8), color: "cyan" };
68
75
  return { text: " none", color: "red" };
@@ -1,4 +1,4 @@
1
- import { listWorktrees, extractTicketId, getBaseBranch, readAllMetadata, getGitStatusAsync, getCommitsAheadAsync, } from "../git.js";
1
+ import { listWorktrees, extractTicketId, getBaseBranch, readAllMetadata, readSessionState, isSessionAliveInTmux, clearSessionState, getGitStatusAsync, getCommitsAheadAsync, } from "../git.js";
2
2
  import { getPRInfoAsync, getPRChecksAsync, getPRReviewsAsync, } from "../github.js";
3
3
  import { fetchAssignedIssues } from "../linear.js";
4
4
  export async function loadDashboardData(repoRoot) {
@@ -38,6 +38,13 @@ export async function loadDashboardData(repoRoot) {
38
38
  getCommitsAheadAsync(wt.path, base),
39
39
  getPRInfoAsync(wt.branch),
40
40
  ]);
41
+ let sessState = readSessionState(repoRoot, issue.identifier);
42
+ // Validate against tmux — if no claude process is running, clear stale state
43
+ if (sessState && !isSessionAliveInTmux(issue.identifier)) {
44
+ clearSessionState(repoRoot, issue.identifier);
45
+ sessState = null;
46
+ }
47
+ const ss = sessState?.state ?? null;
41
48
  worktreeInfo = {
42
49
  path: wt.path,
43
50
  branch: wt.branch,
@@ -45,6 +52,8 @@ export async function loadDashboardData(repoRoot) {
45
52
  commitsAhead: ahead,
46
53
  sessionId: metadata[issue.identifier]?.session_id ?? null,
47
54
  gitStatus: gitStatusOutput,
55
+ sessionState: ss === "exited" ? null : ss,
56
+ sessionMessage: sessState?.message ?? null,
48
57
  };
49
58
  prInfo = pr;
50
59
  if (pr) {
@@ -86,6 +95,12 @@ export async function loadDashboardData(repoRoot) {
86
95
  .replace(/^[A-Z]+-\d+-?/, "") // strip ticket ID
87
96
  .replace(/-/g, " ")
88
97
  .trim() || tid;
98
+ let sessState = readSessionState(repoRoot, tid);
99
+ if (sessState && !isSessionAliveInTmux(tid)) {
100
+ clearSessionState(repoRoot, tid);
101
+ sessState = null;
102
+ }
103
+ const ss = sessState?.state ?? null;
89
104
  return {
90
105
  issue: {
91
106
  identifier: tid,
@@ -106,6 +121,8 @@ export async function loadDashboardData(repoRoot) {
106
121
  commitsAhead: ahead,
107
122
  sessionId: metadata[tid]?.session_id ?? null,
108
123
  gitStatus: gitStatusOutput,
124
+ sessionState: ss === "exited" ? null : ss,
125
+ sessionMessage: sessState?.message ?? null,
109
126
  },
110
127
  pr,
111
128
  checks: checksInfo,
@@ -21,6 +21,8 @@ export interface WorktreeInfo {
21
21
  commitsAhead: number;
22
22
  sessionId: string | null;
23
23
  gitStatus: string;
24
+ sessionState: "waiting" | "idle" | "active" | null;
25
+ sessionMessage: string | null;
24
26
  }
25
27
  export interface DashboardIssue {
26
28
  issue: LinearAssignedIssue;
package/dist/lib/git.d.ts CHANGED
@@ -1,3 +1,9 @@
1
+ export interface SessionState {
2
+ state: "waiting" | "idle" | "active" | "exited";
3
+ message: string | null;
4
+ session_id: string;
5
+ at: string;
6
+ }
1
7
  export interface Worktree {
2
8
  path: string;
3
9
  branch: string;
@@ -245,3 +251,18 @@ export declare function getDiffStat(baseBranch: string): string | null;
245
251
  * Returns null if there are no changes or on failure.
246
252
  */
247
253
  export declare function getDiffContent(baseBranch: string): string | null;
254
+ /**
255
+ * Read the session state file for a given ticket.
256
+ * Returns null if missing or "exited".
257
+ */
258
+ export declare function readSessionState(repoRoot: string, ticketId: string): SessionState | null;
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.
263
+ */
264
+ export declare function isSessionAliveInTmux(ticketId: string): boolean;
265
+ /**
266
+ * Delete the session state file for a given ticket.
267
+ */
268
+ export declare function clearSessionState(repoRoot: string, ticketId: string): void;
package/dist/lib/git.js CHANGED
@@ -216,7 +216,7 @@ export async function removeWorktree(branchName, repoRoot, force = false) {
216
216
  }
217
217
  fs.rmSync(worktreePath, { recursive: true, force: true });
218
218
  }
219
- // Clean up centralized metadata entry
219
+ // Clean up centralized metadata entry and session state
220
220
  const ticketId = extractTicketId(branchName);
221
221
  if (ticketId) {
222
222
  const all = readAllMetadata(repoRoot);
@@ -224,6 +224,7 @@ export async function removeWorktree(branchName, repoRoot, force = false) {
224
224
  delete all[ticketId];
225
225
  writeAllMetadata(repoRoot, all);
226
226
  }
227
+ clearSessionState(repoRoot, ticketId);
227
228
  }
228
229
  // Also delete the branch
229
230
  const deleteFlag = force ? "-D" : "-d";
@@ -584,3 +585,75 @@ export function getDiffStat(baseBranch) {
584
585
  export function getDiffContent(baseBranch) {
585
586
  return run(`git diff ${baseBranch}..HEAD`, { maxBuffer: 10 * 1024 * 1024 }) || null;
586
587
  }
588
+ /**
589
+ * Get the path to the .santree/session-states directory.
590
+ */
591
+ function getSessionStatesDir(repoRoot) {
592
+ return path.join(getSantreeDir(repoRoot), "session-states");
593
+ }
594
+ /**
595
+ * Read the session state file for a given ticket.
596
+ * Returns null if missing or "exited".
597
+ */
598
+ export function readSessionState(repoRoot, ticketId) {
599
+ const filePath = path.join(getSessionStatesDir(repoRoot), `${ticketId}.json`);
600
+ if (!fs.existsSync(filePath))
601
+ return null;
602
+ try {
603
+ const data = JSON.parse(fs.readFileSync(filePath, "utf-8"));
604
+ if (data.state === "exited")
605
+ return null;
606
+ return data;
607
+ }
608
+ catch {
609
+ return null;
610
+ }
611
+ }
612
+ /**
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.
616
+ */
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;
647
+ }
648
+ /**
649
+ * Delete the session state file for a given ticket.
650
+ */
651
+ export function clearSessionState(repoRoot, ticketId) {
652
+ const filePath = path.join(getSessionStatesDir(repoRoot), `${ticketId}.json`);
653
+ try {
654
+ fs.unlinkSync(filePath);
655
+ }
656
+ catch {
657
+ // Ignore if file doesn't exist
658
+ }
659
+ }
@@ -0,0 +1,15 @@
1
+ export type SessionStateValue = "waiting" | "idle" | "active" | "exited";
2
+ export declare function readStdin(): string;
3
+ export declare function extractRepoAndTicket(cwd: string): {
4
+ repoRoot: string;
5
+ ticketId: string;
6
+ } | null;
7
+ export declare function renameTmuxWindow(ticketId: string, state: SessionStateValue): void;
8
+ export declare function runHookScript(repoRoot: string, state: SessionStateValue, env: Record<string, string>): void;
9
+ /**
10
+ * Unified helper: reads stdin, extracts repo/ticket, writes state file,
11
+ * renames tmux window, runs hook script, then exits.
12
+ */
13
+ export declare function signalState(state: SessionStateValue): void;
14
+ export declare function getHooksJson(): Record<string, unknown>;
15
+ export declare function installHooks(): string;
@@ -0,0 +1,150 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { execSync, spawn } from "child_process";
4
+ export function readStdin() {
5
+ try {
6
+ return fs.readFileSync(0, "utf-8");
7
+ }
8
+ catch {
9
+ return "";
10
+ }
11
+ }
12
+ export function extractRepoAndTicket(cwd) {
13
+ const marker = "/.santree/worktrees/";
14
+ const idx = cwd.indexOf(marker);
15
+ if (idx === -1)
16
+ return null;
17
+ const repoRoot = cwd.slice(0, idx);
18
+ const rest = cwd.slice(idx + marker.length);
19
+ const ticketId = rest.split("/")[0];
20
+ if (!ticketId)
21
+ return null;
22
+ return { repoRoot, ticketId };
23
+ }
24
+ export function renameTmuxWindow(ticketId, state) {
25
+ if (!process.env.TMUX)
26
+ return;
27
+ let name;
28
+ switch (state) {
29
+ case "waiting":
30
+ name = `${ticketId} !`;
31
+ break;
32
+ case "idle":
33
+ name = `${ticketId} ~`;
34
+ break;
35
+ default:
36
+ name = ticketId;
37
+ break;
38
+ }
39
+ try {
40
+ execSync(`tmux rename-window "${name}"`, { stdio: "ignore" });
41
+ }
42
+ catch {
43
+ // Ignore tmux errors
44
+ }
45
+ }
46
+ export function runHookScript(repoRoot, state, env) {
47
+ const script = path.join(repoRoot, ".santree", "hooks", `on-${state}.sh`);
48
+ try {
49
+ fs.accessSync(script, fs.constants.X_OK);
50
+ }
51
+ catch {
52
+ return;
53
+ }
54
+ const child = spawn(script, [], {
55
+ cwd: env.SANTREE_WORKTREE_PATH,
56
+ env: { ...process.env, ...env },
57
+ stdio: "ignore",
58
+ detached: true,
59
+ });
60
+ child.unref();
61
+ }
62
+ /**
63
+ * Unified helper: reads stdin, extracts repo/ticket, writes state file,
64
+ * renames tmux window, runs hook script, then exits.
65
+ */
66
+ export function signalState(state) {
67
+ const input = readStdin();
68
+ let data;
69
+ try {
70
+ data = JSON.parse(input);
71
+ }
72
+ catch {
73
+ process.exit(0);
74
+ }
75
+ const cwd = data.cwd || process.cwd();
76
+ const info = extractRepoAndTicket(cwd);
77
+ if (!info) {
78
+ process.exit(0);
79
+ }
80
+ const { repoRoot, ticketId } = info;
81
+ const statesDir = path.join(repoRoot, ".santree", "session-states");
82
+ fs.mkdirSync(statesDir, { recursive: true });
83
+ const stateFile = path.join(statesDir, `${ticketId}.json`);
84
+ const payload = {
85
+ state,
86
+ message: state === "waiting" ? (data.message ?? null) : null,
87
+ session_id: data.session_id ?? "",
88
+ at: new Date().toISOString(),
89
+ };
90
+ fs.writeFileSync(stateFile, JSON.stringify(payload, null, 2) + "\n");
91
+ renameTmuxWindow(ticketId, state);
92
+ const worktreePath = path.join(repoRoot, ".santree", "worktrees", ticketId);
93
+ runHookScript(repoRoot, state, {
94
+ SANTREE_TICKET_ID: ticketId,
95
+ SANTREE_SESSION_STATE: state,
96
+ SANTREE_SESSION_ID: data.session_id ?? "",
97
+ SANTREE_WORKTREE_PATH: worktreePath,
98
+ SANTREE_REPO_ROOT: repoRoot,
99
+ SANTREE_MESSAGE: payload.message ?? "",
100
+ });
101
+ process.exit(0);
102
+ }
103
+ export function getHooksJson() {
104
+ const base = "santree helpers session-signal";
105
+ const opts = { async: true, timeout: 10 };
106
+ return {
107
+ Notification: [
108
+ {
109
+ matcher: "permission_prompt",
110
+ hooks: [{ type: "command", command: `${base} notification`, ...opts }],
111
+ },
112
+ ],
113
+ Stop: [{ hooks: [{ type: "command", command: `${base} stop`, ...opts }] }],
114
+ UserPromptSubmit: [{ hooks: [{ type: "command", command: `${base} prompt`, ...opts }] }],
115
+ SessionEnd: [{ hooks: [{ type: "command", command: `${base} end`, ...opts }] }],
116
+ };
117
+ }
118
+ export function installHooks() {
119
+ const home = process.env.HOME || "";
120
+ const settingsPath = path.join(home, ".claude", "settings.json");
121
+ let settings = {};
122
+ try {
123
+ if (fs.existsSync(settingsPath)) {
124
+ settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
125
+ }
126
+ }
127
+ catch {
128
+ // Start fresh if file is invalid
129
+ }
130
+ const requiredHooks = getHooksJson();
131
+ const existingHooks = settings.hooks || {};
132
+ for (const [event, hookEntries] of Object.entries(requiredHooks)) {
133
+ const existing = existingHooks[event];
134
+ if (!Array.isArray(existing)) {
135
+ existingHooks[event] = hookEntries;
136
+ continue;
137
+ }
138
+ // Remove existing session-signal entries, then add the current ones
139
+ const filtered = existing.filter((entry) => {
140
+ const inner = entry.hooks || [];
141
+ return !inner.some((h) => typeof h.command === "string" && h.command.includes("session-signal"));
142
+ });
143
+ existingHooks[event] = [...filtered, ...hookEntries];
144
+ }
145
+ settings.hooks = existingHooks;
146
+ const claudeDir = path.join(home, ".claude");
147
+ fs.mkdirSync(claudeDir, { recursive: true });
148
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
149
+ return settingsPath;
150
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "santree",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "Git worktree manager",
5
5
  "license": "MIT",
6
6
  "author": "Santiago Toscanini",
@@ -29,7 +29,7 @@
29
29
  "node": ">=20"
30
30
  },
31
31
  "scripts": {
32
- "build": "tsc && chmod +x dist/cli.js",
32
+ "build": "rm -rf dist && tsc && chmod +x dist/cli.js",
33
33
  "dev": "tsc --watch",
34
34
  "start": "node dist/cli.js",
35
35
  "lint": "eslint source",