tmux-ide 1.2.1 → 1.3.1
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/bin/cli.js +14 -14
- package/dist/attach.d.ts +3 -0
- package/dist/attach.js +14 -0
- package/dist/config.d.ts +5 -0
- package/dist/config.js +269 -0
- package/dist/detect.d.ts +15 -0
- package/dist/detect.js +228 -0
- package/dist/doctor.d.ts +3 -0
- package/dist/doctor.js +66 -0
- package/dist/init.d.ts +4 -0
- package/dist/init.js +67 -0
- package/dist/inspect.d.ts +54 -0
- package/dist/inspect.js +118 -0
- package/dist/launch.d.ts +22 -0
- package/dist/launch.js +174 -0
- package/dist/lib/dot-path.d.ts +2 -0
- package/dist/lib/dot-path.js +17 -0
- package/dist/lib/errors.d.ts +29 -0
- package/dist/lib/errors.js +37 -0
- package/dist/lib/launch-plan.d.ts +6 -0
- package/dist/lib/launch-plan.js +40 -0
- package/dist/lib/output.d.ts +9 -0
- package/dist/lib/output.js +65 -0
- package/dist/lib/session-monitor.d.ts +12 -0
- package/dist/lib/session-monitor.js +160 -0
- package/dist/lib/session-options.d.ts +13 -0
- package/dist/lib/session-options.js +85 -0
- package/dist/lib/sizes.d.ts +16 -0
- package/dist/lib/sizes.js +36 -0
- package/dist/lib/tmux.d.ts +42 -0
- package/dist/lib/tmux.js +225 -0
- package/dist/lib/yaml-io.d.ts +10 -0
- package/dist/lib/yaml-io.js +24 -0
- package/dist/ls.d.ts +3 -0
- package/dist/ls.js +35 -0
- package/dist/restart.d.ts +4 -0
- package/dist/restart.js +13 -0
- package/dist/status.d.ts +3 -0
- package/dist/status.js +29 -0
- package/dist/stop.d.ts +3 -0
- package/dist/stop.js +21 -0
- package/dist/types.d.ts +41 -0
- package/dist/types.js +1 -0
- package/dist/validate.d.ts +4 -0
- package/dist/validate.js +172 -0
- package/package.json +16 -10
- package/src/attach.js +0 -17
- package/src/config.js +0 -341
- package/src/detect.js +0 -232
- package/src/doctor.js +0 -91
- package/src/init.js +0 -73
- package/src/inspect.js +0 -127
- package/src/launch.js +0 -245
- package/src/lib/dot-path.js +0 -16
- package/src/lib/errors.js +0 -35
- package/src/lib/launch-plan.js +0 -49
- package/src/lib/output.js +0 -68
- package/src/lib/session-monitor.js +0 -179
- package/src/lib/session-options.js +0 -95
- package/src/lib/sizes.js +0 -36
- package/src/lib/tmux.js +0 -237
- package/src/lib/yaml-io.js +0 -26
- package/src/ls.js +0 -40
- package/src/restart.js +0 -16
- package/src/status.js +0 -35
- package/src/stop.js +0 -25
- package/src/validate.js +0 -154
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
const INTERVAL = 1000;
|
|
5
|
+
const SPINNERS = /^[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏⠂⠒⠢⠆⠐⠠⠄◐◓◑◒|/\\-] /;
|
|
6
|
+
// --- Port detection (pure helpers) ---
|
|
7
|
+
function getListeningPids() {
|
|
8
|
+
// Returns Set of PIDs that have a listening TCP port in range 1024-20000
|
|
9
|
+
try {
|
|
10
|
+
const raw = execFileSync("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN", "-FpPn"], {
|
|
11
|
+
encoding: "utf-8",
|
|
12
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
13
|
+
});
|
|
14
|
+
const pids = new Set();
|
|
15
|
+
let currentPid = null;
|
|
16
|
+
for (const line of raw.split("\n")) {
|
|
17
|
+
if (line.startsWith("p")) {
|
|
18
|
+
currentPid = line.slice(1);
|
|
19
|
+
}
|
|
20
|
+
else if (line.startsWith("n") && currentPid) {
|
|
21
|
+
const match = line.match(/:(\d+)$/);
|
|
22
|
+
if (match) {
|
|
23
|
+
const port = parseInt(match[1], 10);
|
|
24
|
+
if (port >= 1024 && port <= 20000)
|
|
25
|
+
pids.add(currentPid);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return pids;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return new Set();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function getProcessTree() {
|
|
36
|
+
// Returns Map<pid, ppid>
|
|
37
|
+
try {
|
|
38
|
+
const raw = execFileSync("ps", ["-axo", "pid=,ppid="], {
|
|
39
|
+
encoding: "utf-8",
|
|
40
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
41
|
+
});
|
|
42
|
+
const tree = new Map();
|
|
43
|
+
for (const line of raw.trim().split("\n")) {
|
|
44
|
+
const parts = line.trim().split(/\s+/);
|
|
45
|
+
if (parts.length === 2)
|
|
46
|
+
tree.set(parts[0], parts[1]);
|
|
47
|
+
}
|
|
48
|
+
return tree;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return new Map();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export function computePortPanes(panes, { listeners, tree } = {}) {
|
|
55
|
+
// Walk up from each listening PID to find which pane owns it
|
|
56
|
+
const resolvedListeners = listeners ?? getListeningPids();
|
|
57
|
+
const resolvedTree = tree ?? getProcessTree();
|
|
58
|
+
if (resolvedListeners.size === 0)
|
|
59
|
+
return new Set();
|
|
60
|
+
const panePids = new Map(panes.map((p) => [p.pid, p.id]));
|
|
61
|
+
const result = new Set();
|
|
62
|
+
for (const listenerPid of resolvedListeners) {
|
|
63
|
+
let pid = listenerPid;
|
|
64
|
+
while (pid && pid !== "0") {
|
|
65
|
+
if (panePids.has(pid)) {
|
|
66
|
+
result.add(panePids.get(pid));
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
pid = resolvedTree.get(pid);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
// --- Agent detection ---
|
|
75
|
+
export function computeAgentStates(panes) {
|
|
76
|
+
// Returns Map<paneId, "busy" | "idle" | null>
|
|
77
|
+
const states = new Map();
|
|
78
|
+
for (const pane of panes) {
|
|
79
|
+
const cmd = (pane.cmd ?? "").toLowerCase();
|
|
80
|
+
if (!cmd.includes("claude") && !cmd.includes("codex")) {
|
|
81
|
+
states.set(pane.id, null);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
states.set(pane.id, SPINNERS.test(pane.title ?? "") ? "busy" : "idle");
|
|
85
|
+
}
|
|
86
|
+
return states;
|
|
87
|
+
}
|
|
88
|
+
// --- Main loop (only runs when executed directly) ---
|
|
89
|
+
const isMainModule = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
90
|
+
if (isMainModule) {
|
|
91
|
+
const session = process.argv[2];
|
|
92
|
+
if (!session)
|
|
93
|
+
process.exit(1);
|
|
94
|
+
function tmux(...args) {
|
|
95
|
+
return execFileSync("tmux", args, { encoding: "utf-8" }).trim();
|
|
96
|
+
}
|
|
97
|
+
function tmuxSilent(...args) {
|
|
98
|
+
try {
|
|
99
|
+
return tmux(...args);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return "";
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function sessionExists() {
|
|
106
|
+
try {
|
|
107
|
+
tmux("has-session", "-t", session);
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function hasClients() {
|
|
115
|
+
return tmuxSilent("list-clients").length > 0;
|
|
116
|
+
}
|
|
117
|
+
function listPanes() {
|
|
118
|
+
const raw = tmuxSilent("list-panes", "-t", session, "-F", "#{pane_id}\t#{pane_pid}\t#{pane_current_command}\t#{pane_title}");
|
|
119
|
+
if (!raw)
|
|
120
|
+
return [];
|
|
121
|
+
return raw.split("\n").map((line) => {
|
|
122
|
+
const [id, pid, cmd, title] = line.split("\t");
|
|
123
|
+
return { id: id, pid: pid, cmd, title };
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
let lastState = "";
|
|
127
|
+
function tick() {
|
|
128
|
+
if (!sessionExists())
|
|
129
|
+
process.exit(0);
|
|
130
|
+
if (!hasClients())
|
|
131
|
+
return; // skip when nobody is watching
|
|
132
|
+
const panes = listPanes();
|
|
133
|
+
if (panes.length === 0)
|
|
134
|
+
return;
|
|
135
|
+
const portPanes = computePortPanes(panes);
|
|
136
|
+
const agentStates = computeAgentStates(panes);
|
|
137
|
+
// Build state fingerprint for change detection
|
|
138
|
+
const stateKey = panes
|
|
139
|
+
.map((p) => {
|
|
140
|
+
const port = portPanes.has(p.id) ? "1" : "0";
|
|
141
|
+
const agent = agentStates.get(p.id) ?? "-";
|
|
142
|
+
return `${p.id}:${port}:${agent}`;
|
|
143
|
+
})
|
|
144
|
+
.join("|");
|
|
145
|
+
if (stateKey === lastState)
|
|
146
|
+
return;
|
|
147
|
+
// Apply changes
|
|
148
|
+
for (const pane of panes) {
|
|
149
|
+
const hasPort = portPanes.has(pane.id) ? "1" : "0";
|
|
150
|
+
const agent = agentStates.get(pane.id);
|
|
151
|
+
tmuxSilent("set-option", "-pqt", pane.id, "@has_port", hasPort);
|
|
152
|
+
tmuxSilent("set-option", "-pqt", pane.id, "@agent_busy", agent === "busy" ? "1" : "0");
|
|
153
|
+
tmuxSilent("set-option", "-pqt", pane.id, "@agent_idle", agent === "idle" ? "1" : "0");
|
|
154
|
+
}
|
|
155
|
+
tmuxSilent("refresh-client", "-S");
|
|
156
|
+
lastState = stateKey;
|
|
157
|
+
}
|
|
158
|
+
setInterval(tick, INTERVAL);
|
|
159
|
+
tick(); // run immediately
|
|
160
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composable builders for tmux session configuration.
|
|
3
|
+
* Each returns an array of tmux command arrays.
|
|
4
|
+
*/
|
|
5
|
+
import type { ThemeConfig, TmuxCommand } from "../types.ts";
|
|
6
|
+
export declare function buildSessionOptions(session: string, { theme }?: {
|
|
7
|
+
theme?: ThemeConfig;
|
|
8
|
+
}): TmuxCommand[];
|
|
9
|
+
export declare function themeOptions(session: string, theme: ThemeConfig): TmuxCommand[];
|
|
10
|
+
export declare function borderOptions(session: string, theme: ThemeConfig): TmuxCommand[];
|
|
11
|
+
export declare function behaviorOptions(session: string): TmuxCommand[];
|
|
12
|
+
export declare function statusBarOptions(session: string, theme: ThemeConfig): TmuxCommand[];
|
|
13
|
+
export declare function keyBindings(): TmuxCommand[];
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composable builders for tmux session configuration.
|
|
3
|
+
* Each returns an array of tmux command arrays.
|
|
4
|
+
*/
|
|
5
|
+
export function buildSessionOptions(session, { theme = {} } = {}) {
|
|
6
|
+
return [
|
|
7
|
+
...themeOptions(session, theme),
|
|
8
|
+
...borderOptions(session, theme),
|
|
9
|
+
...behaviorOptions(session),
|
|
10
|
+
...statusBarOptions(session, theme),
|
|
11
|
+
...keyBindings(),
|
|
12
|
+
];
|
|
13
|
+
}
|
|
14
|
+
export function themeOptions(session, theme) {
|
|
15
|
+
const accent = theme.accent ?? "colour75";
|
|
16
|
+
const border = theme.border ?? "colour238";
|
|
17
|
+
const bg = theme.bg ?? "colour235";
|
|
18
|
+
const fg = theme.fg ?? "colour248";
|
|
19
|
+
return [
|
|
20
|
+
["set-option", "-t", session, "status-style", `bg=${bg},fg=${fg}`],
|
|
21
|
+
["set-option", "-t", session, "pane-border-style", `fg=${border}`],
|
|
22
|
+
["set-option", "-t", session, "pane-active-border-style", `fg=${accent}`],
|
|
23
|
+
];
|
|
24
|
+
}
|
|
25
|
+
export function borderOptions(session, theme) {
|
|
26
|
+
const accent = theme.accent ?? "colour75";
|
|
27
|
+
const border = theme.border ?? "colour238";
|
|
28
|
+
const fg = theme.fg ?? "colour248";
|
|
29
|
+
return [
|
|
30
|
+
["set-option", "-t", session, "pane-border-status", "top"],
|
|
31
|
+
[
|
|
32
|
+
"set-option",
|
|
33
|
+
"-t",
|
|
34
|
+
session,
|
|
35
|
+
"pane-border-format",
|
|
36
|
+
` #{?pane_active,#[fg=${accent}#,bold]▸ #T #[fg=${fg}]#{pane_current_path},#[fg=${border}]· #T #{pane_current_path}} `,
|
|
37
|
+
],
|
|
38
|
+
];
|
|
39
|
+
}
|
|
40
|
+
export function behaviorOptions(session) {
|
|
41
|
+
return [
|
|
42
|
+
["set-option", "-t", session, "mouse", "on"],
|
|
43
|
+
["set-option", "-t", session, "escape-time", "0"],
|
|
44
|
+
["set-option", "-t", session, "status-interval", "1"],
|
|
45
|
+
];
|
|
46
|
+
}
|
|
47
|
+
export function statusBarOptions(session, theme) {
|
|
48
|
+
const accent = theme.accent ?? "colour75";
|
|
49
|
+
const border = theme.border ?? "colour238";
|
|
50
|
+
const fg = theme.fg ?? "colour248";
|
|
51
|
+
// Pane tab components — each is a self-contained piece
|
|
52
|
+
const agentIndicator = [
|
|
53
|
+
`#{?#{==:#{@agent_busy},1},#[fg=${accent}]⏺ ,`,
|
|
54
|
+
`#{?#{==:#{@agent_idle},1},#[fg=${border}]● ,}}`,
|
|
55
|
+
].join("");
|
|
56
|
+
const portIndicator = `#{?#{==:#{@has_port},1},#[fg=green]⏺ ,}`;
|
|
57
|
+
const paneStyle = `#{?pane_active,#[fg=${accent}],#[fg=${border}]}`;
|
|
58
|
+
const paneTab = `${agentIndicator}${portIndicator}${paneStyle}#[range=pane|#{pane_id}] #T #[norange]#[default]`;
|
|
59
|
+
const separator = `#{?loop_last_flag,,#[fg=${border}]│}`;
|
|
60
|
+
return [
|
|
61
|
+
[
|
|
62
|
+
"set-option",
|
|
63
|
+
"-t",
|
|
64
|
+
session,
|
|
65
|
+
"status-left",
|
|
66
|
+
`#[fg=colour0,bg=${accent},bold] ${session.toUpperCase()} IDE #[default] `,
|
|
67
|
+
],
|
|
68
|
+
["set-option", "-t", session, "status-left-length", "30"],
|
|
69
|
+
[
|
|
70
|
+
"set-option",
|
|
71
|
+
"-t",
|
|
72
|
+
session,
|
|
73
|
+
"status-right",
|
|
74
|
+
`#[fg=colour243]%H:%M #[fg=${accent}]│ #[fg=${fg}]%b %d `,
|
|
75
|
+
],
|
|
76
|
+
["set-option", "-t", session, "status-justify", "centre"],
|
|
77
|
+
["set-option", "-t", session, "window-status-current-format", `#[fg=${accent},bold]●`],
|
|
78
|
+
["set-option", "-t", session, "window-status-format", `#[fg=${border}]○`],
|
|
79
|
+
["set-option", "-t", session, "status", "2"],
|
|
80
|
+
["set-option", "-t", session, "status-format[1]", ` #{P:${paneTab}${separator}}`],
|
|
81
|
+
];
|
|
82
|
+
}
|
|
83
|
+
export function keyBindings() {
|
|
84
|
+
return [["bind-key", "-n", "MouseDown1StatusDefault", "select-pane", "-t", "="]];
|
|
85
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compute absolute sizes for items where some have explicit sizes and others don't.
|
|
3
|
+
* Items with `size` (e.g. "70%") keep their value; remaining space is split equally
|
|
4
|
+
* among items without a size.
|
|
5
|
+
*/
|
|
6
|
+
export declare function computeSizes(items: {
|
|
7
|
+
size?: string;
|
|
8
|
+
}[]): number[];
|
|
9
|
+
/**
|
|
10
|
+
* Convert absolute sizes (e.g. [70, 30]) to tmux split percentages for sequential splits.
|
|
11
|
+
* Returns array of -p values (one per split after the first item).
|
|
12
|
+
*
|
|
13
|
+
* Each tmux split divides the current pane. The percentage given to -p is
|
|
14
|
+
* the portion allocated to the NEW (bottom/right) pane.
|
|
15
|
+
*/
|
|
16
|
+
export declare function toSplitPercents(sizes: number[]): number[];
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compute absolute sizes for items where some have explicit sizes and others don't.
|
|
3
|
+
* Items with `size` (e.g. "70%") keep their value; remaining space is split equally
|
|
4
|
+
* among items without a size.
|
|
5
|
+
*/
|
|
6
|
+
export function computeSizes(items) {
|
|
7
|
+
let claimed = 0;
|
|
8
|
+
let unclaimed = 0;
|
|
9
|
+
for (const item of items) {
|
|
10
|
+
if (item.size) {
|
|
11
|
+
claimed += parseFloat(item.size);
|
|
12
|
+
}
|
|
13
|
+
else {
|
|
14
|
+
unclaimed++;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
const remaining = Math.max(0, 100 - claimed);
|
|
18
|
+
const defaultSize = unclaimed > 0 ? remaining / unclaimed : 0;
|
|
19
|
+
return items.map((item) => (item.size ? parseFloat(item.size) : defaultSize));
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Convert absolute sizes (e.g. [70, 30]) to tmux split percentages for sequential splits.
|
|
23
|
+
* Returns array of -p values (one per split after the first item).
|
|
24
|
+
*
|
|
25
|
+
* Each tmux split divides the current pane. The percentage given to -p is
|
|
26
|
+
* the portion allocated to the NEW (bottom/right) pane.
|
|
27
|
+
*/
|
|
28
|
+
export function toSplitPercents(sizes) {
|
|
29
|
+
const percents = [];
|
|
30
|
+
for (let i = 1; i < sizes.length; i++) {
|
|
31
|
+
const remaining = sizes.slice(i - 1).reduce((a, b) => a + b, 0);
|
|
32
|
+
const topShare = sizes[i - 1];
|
|
33
|
+
percents.push(Math.round(((remaining - topShare) / remaining) * 100));
|
|
34
|
+
}
|
|
35
|
+
return percents;
|
|
36
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
2
|
+
import { TmuxError } from "./errors.ts";
|
|
3
|
+
import type { SessionState } from "../types.ts";
|
|
4
|
+
export { TmuxError };
|
|
5
|
+
export declare function getSessionState(session: string): SessionState;
|
|
6
|
+
export declare function attachSession(session: string): void;
|
|
7
|
+
export declare function hasSession(session: string): boolean;
|
|
8
|
+
export declare function killSession(session: string): {
|
|
9
|
+
stopped: boolean;
|
|
10
|
+
reason: string | null;
|
|
11
|
+
};
|
|
12
|
+
export declare function listPanes(session: string): {
|
|
13
|
+
index: number;
|
|
14
|
+
title: string | undefined;
|
|
15
|
+
width: number;
|
|
16
|
+
height: number;
|
|
17
|
+
active: boolean;
|
|
18
|
+
}[];
|
|
19
|
+
export declare function createDetachedSession(session: string, cwd: string, { cols, lines }?: {
|
|
20
|
+
cols?: number;
|
|
21
|
+
lines?: number;
|
|
22
|
+
}): string;
|
|
23
|
+
export declare function setSessionEnvironment(session: string, key: string, value: string | number): void;
|
|
24
|
+
export declare function splitPane(targetPane: string, direction: string, cwd: string, percent: number): string;
|
|
25
|
+
export declare function sendLiteral(targetPane: string, text: string): void;
|
|
26
|
+
export declare function getPaneCurrentCommand(targetPane: string): string;
|
|
27
|
+
export declare function selectPane(targetPane: string): void;
|
|
28
|
+
export declare function setPaneTitle(targetPane: string, title: string): void;
|
|
29
|
+
export declare function runSessionCommand(args: string[]): void;
|
|
30
|
+
export declare function startSessionMonitor(session: string, monitorScript: string): void;
|
|
31
|
+
export declare function stopSessionMonitor(session: string): void;
|
|
32
|
+
export declare function getSessionVariable(session: string, name: string): string | null;
|
|
33
|
+
export declare function setSessionVariable(session: string, name: string, value: string): void;
|
|
34
|
+
type Executor = typeof execFileSync;
|
|
35
|
+
type Spawner = typeof spawn;
|
|
36
|
+
/** @internal Replace the executor for testing. Returns a restore function. */
|
|
37
|
+
export declare function _setExecutor(fn: Executor): () => void;
|
|
38
|
+
/** @internal Replace the spawner for testing. Returns a restore function. */
|
|
39
|
+
export declare function _setSpawner(fn: Spawner): () => void;
|
|
40
|
+
declare global {
|
|
41
|
+
var __tmuxIdeVerbose: boolean | undefined;
|
|
42
|
+
}
|
package/dist/lib/tmux.js
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
2
|
+
import { TmuxError } from "./errors.js";
|
|
3
|
+
const DEBUG = process.env.TMUX_IDE_DEBUG === "1";
|
|
4
|
+
const SESSION_NOT_FOUND_PATTERNS = ["can't find session", "can't find window", "unknown target"];
|
|
5
|
+
const TMUX_UNAVAILABLE_PATTERNS = [
|
|
6
|
+
"failed to connect to server",
|
|
7
|
+
"no server running",
|
|
8
|
+
"error connecting to",
|
|
9
|
+
"connection refused",
|
|
10
|
+
];
|
|
11
|
+
export { TmuxError };
|
|
12
|
+
export function getSessionState(session) {
|
|
13
|
+
try {
|
|
14
|
+
runTmux(["has-session", "-t", session]);
|
|
15
|
+
return { running: true, reason: null };
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
if (error instanceof TmuxError) {
|
|
19
|
+
if (error.code === "SESSION_NOT_FOUND") {
|
|
20
|
+
return { running: false, reason: "SESSION_NOT_FOUND" };
|
|
21
|
+
}
|
|
22
|
+
if (error.code === "TMUX_UNAVAILABLE") {
|
|
23
|
+
return { running: false, reason: "TMUX_UNAVAILABLE" };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function attachSession(session) {
|
|
30
|
+
runTmux(["attach", "-t", session], { stdio: "inherit" });
|
|
31
|
+
}
|
|
32
|
+
export function hasSession(session) {
|
|
33
|
+
try {
|
|
34
|
+
runTmux(["has-session", "-t", session]);
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (error instanceof TmuxError &&
|
|
39
|
+
(error.code === "SESSION_NOT_FOUND" || error.code === "TMUX_UNAVAILABLE")) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export function killSession(session) {
|
|
46
|
+
try {
|
|
47
|
+
runTmux(["kill-session", "-t", session]);
|
|
48
|
+
return { stopped: true, reason: null };
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (error instanceof TmuxError) {
|
|
52
|
+
if (error.code === "SESSION_NOT_FOUND") {
|
|
53
|
+
return { stopped: false, reason: "SESSION_NOT_FOUND" };
|
|
54
|
+
}
|
|
55
|
+
if (error.code === "TMUX_UNAVAILABLE") {
|
|
56
|
+
return { stopped: false, reason: "TMUX_UNAVAILABLE" };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export function listPanes(session) {
|
|
63
|
+
const raw = runTmux([
|
|
64
|
+
"list-panes",
|
|
65
|
+
"-t",
|
|
66
|
+
session,
|
|
67
|
+
"-F",
|
|
68
|
+
"#{pane_index}|#{pane_title}|#{pane_width}|#{pane_height}|#{pane_active}",
|
|
69
|
+
], { encoding: "utf-8" }).trim();
|
|
70
|
+
if (!raw)
|
|
71
|
+
return [];
|
|
72
|
+
return raw.split("\n").map((line) => {
|
|
73
|
+
const [index, title, width, height, active] = line.split("|");
|
|
74
|
+
return {
|
|
75
|
+
index: Number.parseInt(index, 10),
|
|
76
|
+
title,
|
|
77
|
+
width: Number.parseInt(width, 10),
|
|
78
|
+
height: Number.parseInt(height, 10),
|
|
79
|
+
active: active === "1",
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
export function createDetachedSession(session, cwd, { cols, lines } = {}) {
|
|
84
|
+
return runTmux([
|
|
85
|
+
"new-session",
|
|
86
|
+
"-d",
|
|
87
|
+
"-P",
|
|
88
|
+
"-F",
|
|
89
|
+
"#{pane_id}",
|
|
90
|
+
"-s",
|
|
91
|
+
session,
|
|
92
|
+
"-c",
|
|
93
|
+
cwd,
|
|
94
|
+
"-x",
|
|
95
|
+
String(cols ?? 200),
|
|
96
|
+
"-y",
|
|
97
|
+
String(lines ?? 50),
|
|
98
|
+
], { encoding: "utf-8" }).trim();
|
|
99
|
+
}
|
|
100
|
+
export function setSessionEnvironment(session, key, value) {
|
|
101
|
+
runTmux(["set-environment", "-t", session, key, String(value)]);
|
|
102
|
+
}
|
|
103
|
+
export function splitPane(targetPane, direction, cwd, percent) {
|
|
104
|
+
return runTmux([
|
|
105
|
+
"split-window",
|
|
106
|
+
"-P",
|
|
107
|
+
"-F",
|
|
108
|
+
"#{pane_id}",
|
|
109
|
+
"-t",
|
|
110
|
+
targetPane,
|
|
111
|
+
direction === "vertical" ? "-v" : "-h",
|
|
112
|
+
"-c",
|
|
113
|
+
cwd,
|
|
114
|
+
"-p",
|
|
115
|
+
String(percent),
|
|
116
|
+
], { encoding: "utf-8" }).trim();
|
|
117
|
+
}
|
|
118
|
+
export function sendLiteral(targetPane, text) {
|
|
119
|
+
runTmux(["send-keys", "-t", targetPane, "-l", "--", text], { stdio: "inherit" });
|
|
120
|
+
runTmux(["send-keys", "-t", targetPane, "Enter"], { stdio: "inherit" });
|
|
121
|
+
}
|
|
122
|
+
export function getPaneCurrentCommand(targetPane) {
|
|
123
|
+
return runTmux(["display-message", "-p", "-t", targetPane, "#{pane_current_command}"], {
|
|
124
|
+
encoding: "utf-8",
|
|
125
|
+
}).trim();
|
|
126
|
+
}
|
|
127
|
+
export function selectPane(targetPane) {
|
|
128
|
+
runTmux(["select-pane", "-t", targetPane], { stdio: "inherit" });
|
|
129
|
+
}
|
|
130
|
+
export function setPaneTitle(targetPane, title) {
|
|
131
|
+
runTmux(["select-pane", "-t", targetPane, "-T", title], { stdio: "inherit" });
|
|
132
|
+
}
|
|
133
|
+
export function runSessionCommand(args) {
|
|
134
|
+
runTmux(args, { stdio: "inherit" });
|
|
135
|
+
}
|
|
136
|
+
export function startSessionMonitor(session, monitorScript) {
|
|
137
|
+
const child = _spawner("node", [monitorScript, session], {
|
|
138
|
+
detached: true,
|
|
139
|
+
stdio: "ignore",
|
|
140
|
+
});
|
|
141
|
+
child.unref();
|
|
142
|
+
// Store PID as tmux session variable for later cleanup
|
|
143
|
+
runTmux(["set-option", "-t", session, "@monitor_pid", String(child.pid)]);
|
|
144
|
+
}
|
|
145
|
+
export function stopSessionMonitor(session) {
|
|
146
|
+
try {
|
|
147
|
+
const pid = runTmux(["show-option", "-gqvt", session, "@monitor_pid"], {
|
|
148
|
+
encoding: "utf-8",
|
|
149
|
+
}).trim();
|
|
150
|
+
if (pid)
|
|
151
|
+
process.kill(parseInt(pid, 10));
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
/* session or process already gone */
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
export function getSessionVariable(session, name) {
|
|
158
|
+
try {
|
|
159
|
+
const raw = runTmux(["show-option", "-gqvt", session, name], {
|
|
160
|
+
encoding: "utf-8",
|
|
161
|
+
});
|
|
162
|
+
return raw.trim() || null;
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
export function setSessionVariable(session, name, value) {
|
|
169
|
+
runTmux(["set-option", "-t", session, name, value]);
|
|
170
|
+
}
|
|
171
|
+
let _executor = execFileSync;
|
|
172
|
+
let _spawner = spawn;
|
|
173
|
+
/** @internal Replace the executor for testing. Returns a restore function. */
|
|
174
|
+
export function _setExecutor(fn) {
|
|
175
|
+
const prev = _executor;
|
|
176
|
+
_executor = fn;
|
|
177
|
+
return () => {
|
|
178
|
+
_executor = prev;
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
/** @internal Replace the spawner for testing. Returns a restore function. */
|
|
182
|
+
export function _setSpawner(fn) {
|
|
183
|
+
const prev = _spawner;
|
|
184
|
+
_spawner = fn;
|
|
185
|
+
return () => {
|
|
186
|
+
_spawner = prev;
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
function runTmux(args, options = {}) {
|
|
190
|
+
if (DEBUG || globalThis.__tmuxIdeVerbose) {
|
|
191
|
+
console.error(` [tmux] ${args.join(" ")}`);
|
|
192
|
+
}
|
|
193
|
+
const execOptions = {
|
|
194
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
195
|
+
...options,
|
|
196
|
+
};
|
|
197
|
+
try {
|
|
198
|
+
return _executor("tmux", args, execOptions);
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
throw classifyTmuxError(error);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
function classifyTmuxError(error) {
|
|
205
|
+
const detail = getErrorDetail(error).toLowerCase();
|
|
206
|
+
if (SESSION_NOT_FOUND_PATTERNS.some((pattern) => detail.includes(pattern))) {
|
|
207
|
+
return new TmuxError("tmux session was not found", "SESSION_NOT_FOUND", {
|
|
208
|
+
cause: error,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
if (TMUX_UNAVAILABLE_PATTERNS.some((pattern) => detail.includes(pattern))) {
|
|
212
|
+
return new TmuxError("tmux is unavailable or its socket is inaccessible", "TMUX_UNAVAILABLE", {
|
|
213
|
+
cause: error,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
return new TmuxError("tmux command failed", "TMUX_ERROR", { cause: error });
|
|
217
|
+
}
|
|
218
|
+
function getErrorDetail(error) {
|
|
219
|
+
const stderr = error?.stderr;
|
|
220
|
+
if (typeof stderr === "string" && stderr.length > 0)
|
|
221
|
+
return stderr;
|
|
222
|
+
if (Buffer.isBuffer(stderr) && stderr.length > 0)
|
|
223
|
+
return stderr.toString("utf-8");
|
|
224
|
+
return error?.message ?? "";
|
|
225
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { IdeConfig } from "../types.ts";
|
|
2
|
+
export declare function readConfig(dir: string): {
|
|
3
|
+
config: IdeConfig;
|
|
4
|
+
configPath: string;
|
|
5
|
+
};
|
|
6
|
+
export declare function writeConfig(dir: string, config: IdeConfig): string;
|
|
7
|
+
export declare function getSessionName(dir: string): {
|
|
8
|
+
name: string;
|
|
9
|
+
source: "config" | "fallback";
|
|
10
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { resolve, basename } from "node:path";
|
|
3
|
+
import yaml from "js-yaml";
|
|
4
|
+
export function readConfig(dir) {
|
|
5
|
+
const configPath = resolve(dir, "ide.yml");
|
|
6
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
7
|
+
const config = yaml.load(raw);
|
|
8
|
+
return { config, configPath };
|
|
9
|
+
}
|
|
10
|
+
export function writeConfig(dir, config) {
|
|
11
|
+
const configPath = resolve(dir, "ide.yml");
|
|
12
|
+
const out = yaml.dump(config, { lineWidth: -1, noRefs: true, quotingType: '"' });
|
|
13
|
+
writeFileSync(configPath, out);
|
|
14
|
+
return configPath;
|
|
15
|
+
}
|
|
16
|
+
export function getSessionName(dir) {
|
|
17
|
+
try {
|
|
18
|
+
const { config } = readConfig(dir);
|
|
19
|
+
return { name: config.name ?? basename(dir), source: config.name ? "config" : "fallback" };
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return { name: basename(dir), source: "fallback" };
|
|
23
|
+
}
|
|
24
|
+
}
|
package/dist/ls.d.ts
ADDED
package/dist/ls.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
export async function ls({ json } = {}) {
|
|
3
|
+
let raw;
|
|
4
|
+
try {
|
|
5
|
+
raw = execSync('tmux list-sessions -F "#{session_name}|#{session_created}|#{session_attached}"', { encoding: "utf-8" }).trim();
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
if (json) {
|
|
9
|
+
console.log(JSON.stringify({ sessions: [] }));
|
|
10
|
+
}
|
|
11
|
+
else {
|
|
12
|
+
console.log("No tmux sessions running.");
|
|
13
|
+
}
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const sessions = raw.split("\n").map((line) => {
|
|
17
|
+
const [name, created, attached] = line.split("|");
|
|
18
|
+
return {
|
|
19
|
+
name,
|
|
20
|
+
created: new Date(parseInt(created) * 1000).toISOString(),
|
|
21
|
+
attached: attached !== "0",
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
if (json) {
|
|
25
|
+
console.log(JSON.stringify({ sessions }, null, 2));
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
// Table output
|
|
29
|
+
console.log("SESSION".padEnd(24) + "CREATED".padEnd(22) + "ATTACHED");
|
|
30
|
+
console.log("─".repeat(54));
|
|
31
|
+
for (const s of sessions) {
|
|
32
|
+
const date = new Date(s.created).toLocaleString();
|
|
33
|
+
console.log(s.name.padEnd(24) + date.padEnd(22) + (s.attached ? "yes" : "no"));
|
|
34
|
+
}
|
|
35
|
+
}
|
package/dist/restart.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { getSessionName } from "./lib/yaml-io.js";
|
|
3
|
+
import { launch } from "./launch.js";
|
|
4
|
+
import { killSession } from "./lib/tmux.js";
|
|
5
|
+
export async function restart(targetDir, { json, attach } = {}) {
|
|
6
|
+
const dir = resolve(targetDir ?? ".");
|
|
7
|
+
const { name: session } = getSessionName(dir);
|
|
8
|
+
const result = killSession(session);
|
|
9
|
+
if (result.stopped) {
|
|
10
|
+
console.log(`Stopped session "${session}"`);
|
|
11
|
+
}
|
|
12
|
+
await launch(dir, { json, attach });
|
|
13
|
+
}
|
package/dist/status.d.ts
ADDED