santree 0.2.6 → 0.2.7
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 +69 -4
- package/dist/commands/doctor.js +91 -20
- package/dist/commands/helpers/session-signal/end.d.ts +2 -0
- package/dist/commands/helpers/session-signal/end.js +13 -0
- package/dist/commands/helpers/session-signal/index.d.ts +1 -0
- package/dist/commands/helpers/session-signal/index.js +1 -0
- package/dist/commands/helpers/session-signal/install.d.ts +8 -0
- package/dist/commands/helpers/session-signal/install.js +24 -0
- package/dist/commands/helpers/session-signal/notification.d.ts +2 -0
- package/dist/commands/helpers/session-signal/notification.js +13 -0
- package/dist/commands/helpers/session-signal/prompt.d.ts +2 -0
- package/dist/commands/helpers/session-signal/prompt.js +13 -0
- package/dist/commands/helpers/session-signal/stop.d.ts +2 -0
- package/dist/commands/helpers/session-signal/stop.js +13 -0
- package/dist/commands/helpers/statusline.js +30 -0
- package/dist/lib/dashboard/DetailPanel.js +12 -0
- package/dist/lib/dashboard/IssueList.js +7 -0
- package/dist/lib/dashboard/data.js +18 -1
- package/dist/lib/dashboard/types.d.ts +2 -0
- package/dist/lib/git.d.ts +21 -0
- package/dist/lib/git.js +74 -1
- package/dist/lib/session-signal.d.ts +15 -0
- package/dist/lib/session-signal.js +150 -0
- package/package.json +2 -2
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
|
|
125
|
-
|
|
|
126
|
-
| `santree helpers shell-init`
|
|
127
|
-
| `santree helpers statusline`
|
|
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
|
package/dist/commands/doctor.js
CHANGED
|
@@ -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
|
|
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
|
|
74
|
-
|
|
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,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,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,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,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,6 +163,14 @@ 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}`);
|
|
@@ -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.
|
|
3
|
+
"version": "0.2.7",
|
|
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",
|