santree 0.2.15 → 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.
- package/README.md +9 -0
- package/dist/commands/dashboard.js +159 -119
- package/dist/commands/doctor.js +66 -1
- package/dist/commands/helpers/text-editor.d.ts +13 -0
- package/dist/commands/helpers/text-editor.js +118 -0
- package/dist/commands/worktree/create.d.ts +1 -0
- package/dist/commands/worktree/create.js +30 -38
- package/dist/lib/ai.js +11 -8
- package/dist/lib/dashboard/MultilineTextArea.js +306 -22
- package/dist/lib/dashboard/Overlays.d.ts +2 -2
- package/dist/lib/dashboard/Overlays.js +6 -5
- package/dist/lib/dashboard/data.js +5 -5
- package/dist/lib/dashboard/external-editor.d.ts +12 -0
- package/dist/lib/dashboard/external-editor.js +74 -0
- package/dist/lib/dashboard/types.d.ts +13 -1
- package/dist/lib/dashboard/types.js +13 -0
- package/dist/lib/git.d.ts +6 -4
- package/dist/lib/git.js +8 -33
- package/dist/lib/multiplexer/cmux.d.ts +2 -0
- package/dist/lib/multiplexer/cmux.js +97 -0
- package/dist/lib/multiplexer/index.d.ts +4 -0
- package/dist/lib/multiplexer/index.js +20 -0
- package/dist/lib/multiplexer/none.d.ts +2 -0
- package/dist/lib/multiplexer/none.js +22 -0
- package/dist/lib/multiplexer/tmux.d.ts +2 -0
- package/dist/lib/multiplexer/tmux.js +82 -0
- package/dist/lib/multiplexer/types.d.ts +23 -0
- package/dist/lib/multiplexer/types.js +3 -0
- package/dist/lib/session-signal.js +5 -8
- package/package.json +1 -1
- package/shell/init.zsh.njk +45 -15
|
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
2
2
|
import { Box, Text } from "ink";
|
|
3
3
|
import Spinner from "ink-spinner";
|
|
4
4
|
import TextInput from "ink-text-input";
|
|
5
|
+
import { MultilineTextArea } from "./MultilineTextArea.js";
|
|
5
6
|
export function CommitOverlay({ width, height, branch, ticketId, gitStatus, phase, message, error, dispatch, onSubmit, }) {
|
|
6
7
|
return (_jsxs(Box, { flexDirection: "column", width: width, height: height, children: [_jsx(Text, { bold: true, color: "cyan", children: "Commit & Push" }), _jsx(Text, { dimColor: true, children: "─".repeat(Math.min(width, 50)) }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "branch: " }), _jsx(Text, { children: branch })] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "ticket: " }), _jsx(Text, { children: ticketId })] }), _jsx(Text, { children: " " }), gitStatus ? (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: "Changes:" }), gitStatus
|
|
7
8
|
.split("\n")
|
|
@@ -20,9 +21,9 @@ export function CommitOverlay({ width, height, branch, ticketId, gitStatus, phas
|
|
|
20
21
|
return (_jsxs(Text, { color: color, children: [" ", line] }, i));
|
|
21
22
|
}), gitStatus.split("\n").length > 8 && (_jsxs(Text, { dimColor: true, children: [" +", gitStatus.split("\n").length - 8, " more"] }))] })) : null, _jsx(Text, { children: " " }), phase === "confirm-stage" && (_jsxs(Text, { children: ["Stage all changes?", " ", _jsx(Text, { color: "cyan", bold: true, children: "y" }), "/", _jsx(Text, { color: "cyan", bold: true, children: "n" })] })), phase === "awaiting-message" && (_jsxs(Box, { children: [_jsx(Text, { children: "Message: " }), _jsx(TextInput, { value: message, onChange: (v) => dispatch({ type: "COMMIT_MESSAGE", message: v }), onSubmit: onSubmit })] })), phase === "committing" && (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), " ", "Committing..."] })), phase === "pushing" && (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), " ", "Pushing..."] })), phase === "done" && (_jsx(Text, { color: "green", bold: true, children: "Committed and pushed!" })), phase === "error" && _jsx(Text, { color: "red", children: error }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "ESC to cancel" })] }));
|
|
22
23
|
}
|
|
23
|
-
export function PrCreateOverlay({ width, height, branch, ticketId, phase, error, url, body, title,
|
|
24
|
-
return (_jsxs(Box, { flexDirection: "column", width: width, height: height, children: [_jsx(Text, { bold: true, color: "cyan", children: "Create Pull Request" }), _jsx(Text, { dimColor: true, children: "─".repeat(Math.min(width, 50)) }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "branch: " }), _jsx(Text, { children: branch })] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "ticket: " }), _jsx(Text, { children: ticketId })] }), _jsx(Text, { children: " " }), phase === "choose-mode" && (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: "How do you want to create this PR?" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", bold: true, children: "f" }), " ", "Fill \u2014 use AI to fill the PR template"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", bold: true, children: "w" }), " ", "Web \u2014 open in browser to edit manually"] })] })), phase === "pushing" && (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), " ", "Pushing branch..."] })), phase === "filling" && (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), " ", "Filling PR template with AI..."] })), phase === "review" && (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: "
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
export function PrCreateOverlay({ width, height, branch, ticketId, phase, error, url, body, title, dispatch, }) {
|
|
25
|
+
return (_jsxs(Box, { flexDirection: "column", width: width, height: height, children: [_jsx(Text, { bold: true, color: "cyan", children: "Create Pull Request" }), _jsx(Text, { dimColor: true, children: "─".repeat(Math.min(width, 50)) }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "branch: " }), _jsx(Text, { children: branch })] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "ticket: " }), _jsx(Text, { children: ticketId })] }), _jsx(Text, { children: " " }), phase === "choose-mode" && (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: "How do you want to create this PR?" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", bold: true, children: "f" }), " ", "Fill \u2014 use AI to fill the PR template"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", bold: true, children: "w" }), " ", "Web \u2014 open in browser to edit manually"] })] })), phase === "pushing" && (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), " ", "Pushing branch..."] })), phase === "filling" && (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), " ", "Filling PR template with AI..."] })), phase === "review" && (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: "Edit PR description" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "title: " }), _jsx(Text, { children: title })] }), _jsx(Text, { children: " " }), _jsx(MultilineTextArea, { value: body ?? "", onChange: (v) => dispatch({ type: "PR_CREATE_BODY_CHANGE", body: v }), onSubmit: () => dispatch({ type: "PR_CREATE_CONFIRM" }), onCancel: () => dispatch({ type: "PR_CREATE_CANCEL" }), width: width, height: Math.max(6, height - 10), placeholder: "(empty PR body)" }), _jsx(Text, { children: " " }), _jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "cyan", bold: true, children: "Ctrl+D" }), " send · ", _jsx(Text, { color: "cyan", bold: true, children: "Ctrl+O" }), " editor · ", _jsx(Text, { color: "cyan", bold: true, children: "Ctrl+C" }), " cancel"] })] })), phase === "confirm" && (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: "Create this PR?" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "title: " }), _jsx(Text, { children: title })] }), _jsx(Text, { children: " " }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 1, children: [(body ?? "")
|
|
26
|
+
.split("\n")
|
|
27
|
+
.slice(0, Math.max(4, height - 12))
|
|
28
|
+
.map((line, i) => (_jsx(Text, { wrap: "truncate", children: line || " " }, i))), (body ?? "").split("\n").length > Math.max(4, height - 12) && (_jsxs(Text, { dimColor: true, children: ["\u2026+", (body ?? "").split("\n").length - Math.max(4, height - 12), " more lines"] }))] }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "green", bold: true, children: "y" }), " / ", _jsx(Text, { color: "green", bold: true, children: "Enter" }), " create ", _jsx(Text, { color: "yellow", bold: true, children: "e" }), " keep editing ", _jsx(Text, { color: "cyan", bold: true, children: "w" }), " open in browser ", _jsx(Text, { color: "red", bold: true, children: "ESC" }), " cancel"] })] })), phase === "creating" && (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), " ", "Creating PR..."] })), phase === "done" && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "green", bold: true, children: "PR created!" }), url ? _jsx(Text, { dimColor: true, children: url }) : null] })), phase === "error" && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "red", children: error }), _jsx(Text, { children: " " }), _jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "cyan", bold: true, children: "w" }), " ", "open in browser ESC cancel"] })] })), phase !== "review" && phase !== "confirm" && phase !== "error" && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "ESC to cancel" })] }))] }));
|
|
28
29
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { listWorktrees, extractTicketId, getBaseBranch, readAllMetadata, readSessionState,
|
|
1
|
+
import { listWorktrees, extractTicketId, getBaseBranch, readAllMetadata, readSessionState, isSessionAlive, clearSessionState, getGitStatusAsync, getCommitsAheadAsync, } from "../git.js";
|
|
2
2
|
import { getPRInfoAsync, getPRChecksAsync, getPRReviewsAsync, getPRConversationCommentsAsync, getPRViewAsync, getReviewRequestedPRsAsync, getRepoNameAsync, } from "../github.js";
|
|
3
3
|
import { fetchAssignedIssues } from "../linear.js";
|
|
4
4
|
export async function loadDashboardData(repoRoot) {
|
|
@@ -39,8 +39,8 @@ export async function loadDashboardData(repoRoot) {
|
|
|
39
39
|
getPRInfoAsync(wt.branch),
|
|
40
40
|
]);
|
|
41
41
|
let sessState = readSessionState(repoRoot, issue.identifier);
|
|
42
|
-
// Validate against
|
|
43
|
-
if (sessState && !
|
|
42
|
+
// Validate against the active multiplexer — if the session has gone, clear stale state
|
|
43
|
+
if (sessState && !isSessionAlive(issue.identifier)) {
|
|
44
44
|
clearSessionState(repoRoot, issue.identifier);
|
|
45
45
|
sessState = null;
|
|
46
46
|
}
|
|
@@ -96,7 +96,7 @@ export async function loadDashboardData(repoRoot) {
|
|
|
96
96
|
.replace(/-/g, " ")
|
|
97
97
|
.trim() || tid;
|
|
98
98
|
let sessState = readSessionState(repoRoot, tid);
|
|
99
|
-
if (sessState && !
|
|
99
|
+
if (sessState && !isSessionAlive(tid)) {
|
|
100
100
|
clearSessionState(repoRoot, tid);
|
|
101
101
|
sessState = null;
|
|
102
102
|
}
|
|
@@ -260,7 +260,7 @@ export async function loadReviewsData(repoRoot) {
|
|
|
260
260
|
getCommitsAheadAsync(wt.path, base),
|
|
261
261
|
]);
|
|
262
262
|
let sessState = ticketId ? readSessionState(repoRoot, ticketId) : null;
|
|
263
|
-
if (sessState && ticketId && !
|
|
263
|
+
if (sessState && ticketId && !isSessionAlive(ticketId)) {
|
|
264
264
|
clearSessionState(repoRoot, ticketId);
|
|
265
265
|
sessState = null;
|
|
266
266
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface EditExternallyResult {
|
|
2
|
+
ok: boolean;
|
|
3
|
+
content: string;
|
|
4
|
+
cancelled: boolean;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Open the user's editor on a temp file seeded with `initial`, then return the
|
|
8
|
+
* saved content. Empty buffer is treated as cancel (matches `git commit`).
|
|
9
|
+
*
|
|
10
|
+
* Editor resolution: SANTREE_EDITOR > VISUAL > EDITOR > "vim".
|
|
11
|
+
*/
|
|
12
|
+
export declare function editExternally(initial: string, ext?: string): EditExternallyResult;
|
|
@@ -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
|
+
}
|
|
@@ -60,7 +60,7 @@ export interface EnrichedReviewPR {
|
|
|
60
60
|
export type DashboardTab = "issues" | "reviews";
|
|
61
61
|
export type ActionOverlay = "mode-select" | "context-input" | "base-select" | "confirm-delete" | "confirm-setup" | "commit" | "pr-create" | null;
|
|
62
62
|
export type CommitPhase = "idle" | "confirm-stage" | "awaiting-message" | "committing" | "pushing" | "done" | "error";
|
|
63
|
-
export type PrCreatePhase = "idle" | "choose-mode" | "pushing" | "filling" | "review" | "creating" | "done" | "error";
|
|
63
|
+
export type PrCreatePhase = "idle" | "choose-mode" | "pushing" | "filling" | "review" | "confirm" | "creating" | "done" | "error";
|
|
64
64
|
export interface DashboardState {
|
|
65
65
|
activeTab: DashboardTab;
|
|
66
66
|
groups: ProjectGroup[];
|
|
@@ -102,6 +102,7 @@ export interface DashboardState {
|
|
|
102
102
|
baseSelectChosen: string | null;
|
|
103
103
|
contextInputValue: string;
|
|
104
104
|
contextInputMode: "plan" | "implement" | null;
|
|
105
|
+
contextInputPhase: "editing" | "review";
|
|
105
106
|
}
|
|
106
107
|
export type DashboardAction = {
|
|
107
108
|
type: "SET_DATA";
|
|
@@ -181,6 +182,13 @@ export type DashboardAction = {
|
|
|
181
182
|
type: "PR_CREATE_REVIEW";
|
|
182
183
|
body: string;
|
|
183
184
|
title: string;
|
|
185
|
+
} | {
|
|
186
|
+
type: "PR_CREATE_BODY_CHANGE";
|
|
187
|
+
body: string;
|
|
188
|
+
} | {
|
|
189
|
+
type: "PR_CREATE_CONFIRM";
|
|
190
|
+
} | {
|
|
191
|
+
type: "PR_CREATE_EDIT";
|
|
184
192
|
} | {
|
|
185
193
|
type: "PR_CREATE_DONE";
|
|
186
194
|
url: string;
|
|
@@ -223,6 +231,10 @@ export type DashboardAction = {
|
|
|
223
231
|
} | {
|
|
224
232
|
type: "CONTEXT_INPUT_CHANGE";
|
|
225
233
|
value: string;
|
|
234
|
+
} | {
|
|
235
|
+
type: "CONTEXT_INPUT_REVIEW";
|
|
236
|
+
} | {
|
|
237
|
+
type: "CONTEXT_INPUT_EDIT";
|
|
226
238
|
} | {
|
|
227
239
|
type: "CONTEXT_INPUT_DONE";
|
|
228
240
|
};
|
|
@@ -40,6 +40,7 @@ export const initialState = {
|
|
|
40
40
|
baseSelectChosen: null,
|
|
41
41
|
contextInputValue: "",
|
|
42
42
|
contextInputMode: null,
|
|
43
|
+
contextInputPhase: "editing",
|
|
43
44
|
};
|
|
44
45
|
export function reducer(state, action) {
|
|
45
46
|
switch (action.type) {
|
|
@@ -165,6 +166,12 @@ export function reducer(state, action) {
|
|
|
165
166
|
prCreateTitle: action.title,
|
|
166
167
|
detailScrollOffset: 0,
|
|
167
168
|
};
|
|
169
|
+
case "PR_CREATE_BODY_CHANGE":
|
|
170
|
+
return { ...state, prCreateBody: action.body };
|
|
171
|
+
case "PR_CREATE_CONFIRM":
|
|
172
|
+
return { ...state, prCreatePhase: "confirm" };
|
|
173
|
+
case "PR_CREATE_EDIT":
|
|
174
|
+
return { ...state, prCreatePhase: "review" };
|
|
168
175
|
case "PR_CREATE_DONE":
|
|
169
176
|
return {
|
|
170
177
|
...state,
|
|
@@ -250,15 +257,21 @@ export function reducer(state, action) {
|
|
|
250
257
|
overlay: "context-input",
|
|
251
258
|
contextInputMode: action.mode,
|
|
252
259
|
contextInputValue: "",
|
|
260
|
+
contextInputPhase: "editing",
|
|
253
261
|
};
|
|
254
262
|
case "CONTEXT_INPUT_CHANGE":
|
|
255
263
|
return { ...state, contextInputValue: action.value };
|
|
264
|
+
case "CONTEXT_INPUT_REVIEW":
|
|
265
|
+
return { ...state, contextInputPhase: "review" };
|
|
266
|
+
case "CONTEXT_INPUT_EDIT":
|
|
267
|
+
return { ...state, contextInputPhase: "editing" };
|
|
256
268
|
case "CONTEXT_INPUT_DONE":
|
|
257
269
|
return {
|
|
258
270
|
...state,
|
|
259
271
|
overlay: null,
|
|
260
272
|
contextInputMode: null,
|
|
261
273
|
contextInputValue: "",
|
|
274
|
+
contextInputPhase: "editing",
|
|
262
275
|
};
|
|
263
276
|
default:
|
|
264
277
|
return state;
|
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
|
|
261
|
-
*
|
|
262
|
-
*
|
|
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
|
|
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
|
|
614
|
-
*
|
|
615
|
-
*
|
|
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
|
|
618
|
-
|
|
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,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,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,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;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
2
|
import * as path from "path";
|
|
3
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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`);
|