tmux-ide 1.0.0 → 1.2.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 +84 -35
- package/bin/cli.js +133 -85
- package/package.json +26 -5
- package/scripts/postinstall.js +46 -7
- package/skill/SKILL.md +63 -12
- package/src/attach.js +7 -12
- package/src/config.js +100 -18
- package/src/detect.js +77 -23
- package/src/doctor.js +23 -14
- package/src/init.js +10 -18
- package/src/inspect.js +127 -0
- package/src/launch.js +186 -179
- package/src/lib/errors.js +35 -0
- package/src/lib/launch-plan.js +49 -0
- package/src/lib/output.js +18 -22
- package/src/lib/session-monitor.js +179 -0
- package/src/lib/session-options.js +95 -0
- package/src/lib/tmux.js +234 -0
- package/src/lib/yaml-io.js +2 -2
- package/src/ls.js +2 -4
- package/src/restart.js +6 -13
- package/src/status.js +5 -23
- package/src/stop.js +12 -10
- package/src/validate.js +1 -17
- package/src/detect.test.js +0 -115
- package/src/integration.test.js +0 -94
- package/src/lib/dot-path.test.js +0 -67
- package/src/lib/sizes.test.js +0 -74
- package/src/validate.test.js +0 -141
package/src/init.js
CHANGED
|
@@ -1,32 +1,24 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
-
import { resolve, basename } from "node:path";
|
|
3
|
-
import {
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { resolve, basename, dirname } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
4
6
|
import { detectStack, suggestConfig } from "./detect.js";
|
|
5
|
-
import { printLayout } from "./lib/output.js";
|
|
7
|
+
import { outputError, printLayout } from "./lib/output.js";
|
|
6
8
|
|
|
7
9
|
export async function init({ template, json } = {}) {
|
|
8
10
|
const dir = process.cwd();
|
|
9
11
|
const configPath = resolve(dir, "ide.yml");
|
|
10
12
|
|
|
11
13
|
if (existsSync(configPath)) {
|
|
12
|
-
|
|
13
|
-
console.error(JSON.stringify({ error: "ide.yml already exists", code: "EXISTS" }));
|
|
14
|
-
} else {
|
|
15
|
-
console.error("ide.yml already exists in this directory");
|
|
16
|
-
}
|
|
17
|
-
process.exit(1);
|
|
14
|
+
outputError("ide.yml already exists in this directory", "EXISTS");
|
|
18
15
|
}
|
|
19
16
|
|
|
20
17
|
// If a specific template is requested, use it
|
|
21
18
|
if (template) {
|
|
22
|
-
const templatePath = resolve(
|
|
19
|
+
const templatePath = resolve(__dirname, "..", "templates", `${template}.yml`);
|
|
23
20
|
if (!existsSync(templatePath)) {
|
|
24
|
-
|
|
25
|
-
console.error(JSON.stringify({ error: `Template "${template}" not found`, code: "NOT_FOUND" }));
|
|
26
|
-
} else {
|
|
27
|
-
console.error(`Template "${template}" not found. Available: default, nextjs, convex, vite, python, go, agent-team, agent-team-nextjs, agent-team-monorepo`);
|
|
28
|
-
}
|
|
29
|
-
process.exit(1);
|
|
21
|
+
outputError(`Template "${template}" not found`, "NOT_FOUND");
|
|
30
22
|
}
|
|
31
23
|
|
|
32
24
|
let content = readFileSync(templatePath, "utf-8");
|
|
@@ -64,7 +56,7 @@ export async function init({ template, json } = {}) {
|
|
|
64
56
|
}
|
|
65
57
|
} else {
|
|
66
58
|
// Fallback to default template
|
|
67
|
-
const templatePath = resolve(
|
|
59
|
+
const templatePath = resolve(__dirname, "..", "templates", "default.yml");
|
|
68
60
|
let content = readFileSync(templatePath, "utf-8");
|
|
69
61
|
content = content.replace(/^name: .+/m, `name: ${name}`);
|
|
70
62
|
writeFileSync(configPath, content);
|
package/src/inspect.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { resolve, basename } from "node:path";
|
|
2
|
+
import { readConfig } from "./lib/yaml-io.js";
|
|
3
|
+
import { validateConfig } from "./validate.js";
|
|
4
|
+
import { outputError } from "./lib/output.js";
|
|
5
|
+
import { getSessionState, listPanes } from "./lib/tmux.js";
|
|
6
|
+
|
|
7
|
+
export function buildInspection(dir, { config, configPath, running, panes }) {
|
|
8
|
+
const errors = validateConfig(config);
|
|
9
|
+
const rows = Array.isArray(config?.rows) ? config.rows : [];
|
|
10
|
+
const resolvedRows = rows.map((row, rowIndex) => ({
|
|
11
|
+
index: rowIndex,
|
|
12
|
+
size: row.size ?? null,
|
|
13
|
+
panes: (Array.isArray(row?.panes) ? row.panes : []).map((pane, paneIndex) => ({
|
|
14
|
+
index: paneIndex,
|
|
15
|
+
title: pane.title ?? null,
|
|
16
|
+
command: pane.command ?? null,
|
|
17
|
+
dir: pane.dir ?? ".",
|
|
18
|
+
size: pane.size ?? null,
|
|
19
|
+
focus: pane.focus === true,
|
|
20
|
+
role: pane.role ?? null,
|
|
21
|
+
task: pane.task ?? null,
|
|
22
|
+
env: pane.env ?? {},
|
|
23
|
+
})),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
const focusPane =
|
|
27
|
+
resolvedRows
|
|
28
|
+
.flatMap((row) => row.panes.map((pane) => ({ row: row.index, pane })))
|
|
29
|
+
.find(({ pane }) => pane.focus) ?? null;
|
|
30
|
+
const session = config?.name ?? basename(dir);
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
dir,
|
|
34
|
+
configPath,
|
|
35
|
+
valid: errors.length === 0,
|
|
36
|
+
errors,
|
|
37
|
+
session,
|
|
38
|
+
before: config?.before ?? null,
|
|
39
|
+
summary: {
|
|
40
|
+
rows: resolvedRows.length,
|
|
41
|
+
panes: resolvedRows.reduce((sum, row) => sum + row.panes.length, 0),
|
|
42
|
+
focus: focusPane ? `rows.${focusPane.row}.panes.${focusPane.pane.index}` : null,
|
|
43
|
+
},
|
|
44
|
+
team: config?.team ?? null,
|
|
45
|
+
theme: config?.theme ?? null,
|
|
46
|
+
focus: focusPane
|
|
47
|
+
? {
|
|
48
|
+
row: focusPane.row,
|
|
49
|
+
pane: focusPane.pane.index,
|
|
50
|
+
title: focusPane.pane.title,
|
|
51
|
+
}
|
|
52
|
+
: null,
|
|
53
|
+
rows: resolvedRows,
|
|
54
|
+
rawConfig: config,
|
|
55
|
+
tmux: {
|
|
56
|
+
running,
|
|
57
|
+
panes,
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function inspect(targetDir, { json } = {}) {
|
|
63
|
+
const dir = resolve(targetDir ?? ".");
|
|
64
|
+
|
|
65
|
+
let config;
|
|
66
|
+
let configPath;
|
|
67
|
+
try {
|
|
68
|
+
({ config, configPath } = readConfig(dir));
|
|
69
|
+
} catch (error) {
|
|
70
|
+
outputError(`Cannot read ide.yml: ${error.message}`, "READ_ERROR");
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const session = config?.name ?? basename(dir);
|
|
75
|
+
const state = getSessionState(session);
|
|
76
|
+
const panes = state.running ? listPanes(session) : [];
|
|
77
|
+
const data = buildInspection(dir, { config, configPath, running: state.running, panes });
|
|
78
|
+
|
|
79
|
+
if (json) {
|
|
80
|
+
console.log(JSON.stringify(data, null, 2));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
console.log(`Directory: ${data.dir}`);
|
|
85
|
+
console.log(`Config: ${data.configPath}`);
|
|
86
|
+
console.log(`Valid: ${data.valid ? "yes" : "no"}`);
|
|
87
|
+
console.log(`Session: ${data.session}`);
|
|
88
|
+
console.log(`Running: ${data.tmux.running ? "yes" : "no"}`);
|
|
89
|
+
console.log(`Rows: ${data.summary.rows}`);
|
|
90
|
+
console.log(`Panes: ${data.summary.panes}`);
|
|
91
|
+
console.log(`Team: ${data.team ? data.team.name : "disabled"}`);
|
|
92
|
+
|
|
93
|
+
if (data.focus) {
|
|
94
|
+
console.log(
|
|
95
|
+
`Focus: row ${data.focus.row}, pane ${data.focus.pane}${data.focus.title ? ` (${data.focus.title})` : ""}`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!data.valid) {
|
|
100
|
+
console.log("\nValidation Errors:");
|
|
101
|
+
for (const error of data.errors) {
|
|
102
|
+
console.log(` - ${error}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
console.log("\nResolved Layout:");
|
|
107
|
+
for (const row of data.rows) {
|
|
108
|
+
console.log(` Row ${row.index}${row.size ? ` (${row.size})` : ""}`);
|
|
109
|
+
for (const pane of row.panes) {
|
|
110
|
+
const parts = [];
|
|
111
|
+
if (pane.title) parts.push(pane.title);
|
|
112
|
+
if (pane.command) parts.push(`cmd=${pane.command}`);
|
|
113
|
+
if (pane.dir && pane.dir !== ".") parts.push(`dir=${pane.dir}`);
|
|
114
|
+
if (pane.role) parts.push(`role=${pane.role}`);
|
|
115
|
+
if (pane.focus) parts.push("focus");
|
|
116
|
+
console.log(` - pane ${pane.index}: ${parts.join(" | ") || "shell"}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (data.tmux.running && data.tmux.panes.length > 0) {
|
|
121
|
+
console.log("\nLive Panes:");
|
|
122
|
+
for (const pane of data.tmux.panes) {
|
|
123
|
+
const active = pane.active ? " (active)" : "";
|
|
124
|
+
console.log(` ${pane.index}: ${pane.title} [${pane.width}x${pane.height}]${active}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
package/src/launch.js
CHANGED
|
@@ -1,238 +1,245 @@
|
|
|
1
|
-
import { resolve } from "node:path";
|
|
1
|
+
import { resolve, dirname } from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
2
3
|
import { execSync } from "node:child_process";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
3
5
|
import { readConfig, getSessionName } from "./lib/yaml-io.js";
|
|
4
6
|
import { computeSizes, toSplitPercents } from "./lib/sizes.js";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
import { outputError } from "./lib/output.js";
|
|
8
|
+
import { collectPaneStartupPlan } from "./lib/launch-plan.js";
|
|
9
|
+
import { buildSessionOptions } from "./lib/session-options.js";
|
|
10
|
+
import {
|
|
11
|
+
attachSession,
|
|
12
|
+
createDetachedSession,
|
|
13
|
+
getPaneCurrentCommand,
|
|
14
|
+
getSessionVariable,
|
|
15
|
+
hasSession,
|
|
16
|
+
runSessionCommand,
|
|
17
|
+
selectPane,
|
|
18
|
+
sendLiteral,
|
|
19
|
+
setPaneTitle,
|
|
20
|
+
setSessionEnvironment,
|
|
21
|
+
setSessionVariable,
|
|
22
|
+
splitPane,
|
|
23
|
+
startSessionMonitor,
|
|
24
|
+
} from "./lib/tmux.js";
|
|
25
|
+
import { validateConfig } from "./validate.js";
|
|
26
|
+
|
|
27
|
+
function sleepMs(ms) {
|
|
28
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
8
29
|
}
|
|
9
30
|
|
|
10
|
-
function
|
|
11
|
-
|
|
12
|
-
execSync(`tmux ${cmd}`, { stdio: "ignore" });
|
|
13
|
-
} catch {}
|
|
31
|
+
function configHash(config) {
|
|
32
|
+
return createHash("sha256").update(JSON.stringify(config)).digest("hex").slice(0, 12);
|
|
14
33
|
}
|
|
15
34
|
|
|
16
|
-
export
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
} catch {
|
|
23
|
-
console.error(`No ide.yml found in ${dir}. Run "tmux-ide init" to create one.`);
|
|
24
|
-
process.exit(1);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
// Validate config structure before building tmux session
|
|
28
|
-
if (!Array.isArray(config.rows) || config.rows.length === 0) {
|
|
29
|
-
console.error("Invalid ide.yml: 'rows' must be a non-empty array");
|
|
30
|
-
console.error("Run: tmux-ide validate");
|
|
31
|
-
process.exit(1);
|
|
32
|
-
}
|
|
33
|
-
for (const row of config.rows) {
|
|
34
|
-
if (!Array.isArray(row.panes) || row.panes.length === 0) {
|
|
35
|
-
console.error("Invalid ide.yml: each row must have a non-empty 'panes' array");
|
|
36
|
-
console.error("Run: tmux-ide validate");
|
|
37
|
-
process.exit(1);
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const session = config.name ?? getSessionName(dir);
|
|
42
|
-
const rows = config.rows;
|
|
43
|
-
const theme = config.theme ?? {};
|
|
44
|
-
const team = config.team ?? null;
|
|
35
|
+
export function waitForPaneCommand(
|
|
36
|
+
targetPane,
|
|
37
|
+
expectedCommands,
|
|
38
|
+
{ attempts = 20, delayMs = 100, getCurrentCommand = getPaneCurrentCommand, sleep = sleepMs } = {},
|
|
39
|
+
) {
|
|
40
|
+
const allowed = new Set(expectedCommands.map((command) => command.toLowerCase()));
|
|
45
41
|
|
|
46
|
-
|
|
47
|
-
if (config.before) {
|
|
48
|
-
console.log(`Running: ${config.before}`);
|
|
42
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
49
43
|
try {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
44
|
+
const current = getCurrentCommand(targetPane)?.trim().toLowerCase();
|
|
45
|
+
if (allowed.has(current)) return true;
|
|
46
|
+
} catch {
|
|
47
|
+
// Fall through to retry; tmux can briefly report transitional state.
|
|
54
48
|
}
|
|
55
|
-
}
|
|
56
49
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
console.log(`Session "${session}" is already running. Attaching...`);
|
|
61
|
-
tmux(`attach -t "${session}"`);
|
|
62
|
-
return;
|
|
63
|
-
} catch {
|
|
64
|
-
// Session doesn't exist, continue with creation
|
|
50
|
+
if (attempt < attempts - 1) {
|
|
51
|
+
sleep(delayMs);
|
|
52
|
+
}
|
|
65
53
|
}
|
|
66
54
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const lines = process.stdout.rows ?? 50;
|
|
70
|
-
|
|
71
|
-
// Create session with first pane
|
|
72
|
-
tmux(`new-session -d -s "${session}" -c "${dir}" -x ${cols} -y ${lines}`);
|
|
73
|
-
|
|
74
|
-
// Set agent teams env var if team config is present
|
|
75
|
-
if (team) {
|
|
76
|
-
tmux(`set-environment -t "${session}" CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS 1`);
|
|
77
|
-
}
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
78
57
|
|
|
79
|
-
|
|
58
|
+
export function buildPaneMap(rows, dir, rootPaneId, splitPane) {
|
|
80
59
|
const rowSizes = computeSizes(rows);
|
|
81
60
|
const rowSplitPercents = toSplitPercents(rowSizes);
|
|
82
61
|
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
const rowPaneIndices = [0]; // rowPaneIndices[i] = tmux pane index for row i
|
|
86
|
-
let nextPaneIndex = 1;
|
|
87
|
-
|
|
62
|
+
// Create all rows vertically first so each row spans the full width.
|
|
63
|
+
const rowPaneIds = [rootPaneId];
|
|
88
64
|
for (let rowIdx = 1; rowIdx < rows.length; rowIdx++) {
|
|
89
|
-
const splitFrom =
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
65
|
+
const splitFrom = rowPaneIds[rowIdx - 1];
|
|
66
|
+
const newPaneId = splitPane({
|
|
67
|
+
targetPane: splitFrom,
|
|
68
|
+
direction: "vertical",
|
|
69
|
+
cwd: dir,
|
|
70
|
+
percent: rowSplitPercents[rowIdx - 1],
|
|
71
|
+
});
|
|
72
|
+
rowPaneIds.push(newPaneId);
|
|
95
73
|
}
|
|
96
74
|
|
|
97
|
-
// Phase 2: Create panes within each row (horizontal splits).
|
|
98
|
-
// paneMap[row][col] = tmux pane index
|
|
99
75
|
const paneMap = [];
|
|
100
|
-
const firstPanesOfRows = new Set(
|
|
76
|
+
const firstPanesOfRows = new Set(rowPaneIds);
|
|
101
77
|
|
|
102
78
|
for (let rowIdx = 0; rowIdx < rows.length; rowIdx++) {
|
|
103
79
|
const row = rows[rowIdx];
|
|
104
80
|
const panes = row.panes ?? [];
|
|
105
|
-
const
|
|
106
|
-
const rowPanes = [
|
|
81
|
+
const rowPaneId = rowPaneIds[rowIdx];
|
|
82
|
+
const rowPanes = [rowPaneId];
|
|
107
83
|
|
|
108
|
-
// Compute pane sizes within this row and convert to split percentages
|
|
109
84
|
const paneSizes = computeSizes(panes);
|
|
110
85
|
const paneSplitPercents = toSplitPercents(paneSizes);
|
|
111
86
|
|
|
112
|
-
// Split this row's panes horizontally
|
|
113
87
|
for (let paneIdx = 1; paneIdx < panes.length; paneIdx++) {
|
|
114
88
|
const pane = panes[paneIdx];
|
|
115
89
|
const targetPane = rowPanes[paneIdx - 1];
|
|
116
|
-
|
|
117
|
-
// Per-pane working directory
|
|
118
90
|
const paneDir = pane.dir ? resolve(dir, pane.dir) : dir;
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
91
|
+
const newPaneId = splitPane({
|
|
92
|
+
targetPane,
|
|
93
|
+
direction: "horizontal",
|
|
94
|
+
cwd: paneDir,
|
|
95
|
+
percent: paneSplitPercents[paneIdx - 1],
|
|
96
|
+
});
|
|
97
|
+
rowPanes.push(newPaneId);
|
|
125
98
|
}
|
|
126
99
|
|
|
127
100
|
paneMap.push(rowPanes);
|
|
128
101
|
}
|
|
129
102
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
103
|
+
return { paneMap, firstPanesOfRows };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function loadLaunchConfig(dir) {
|
|
107
|
+
let config;
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
({ config } = readConfig(dir));
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (error?.code === "ENOENT") {
|
|
113
|
+
outputError(
|
|
114
|
+
`No ide.yml found in ${dir}. Run "tmux-ide init" or "tmux-ide detect --write" to create one.`,
|
|
115
|
+
"CONFIG_NOT_FOUND",
|
|
116
|
+
);
|
|
143
117
|
}
|
|
144
|
-
|
|
118
|
+
|
|
119
|
+
outputError(`Cannot read ide.yml: ${error.message}`, "READ_ERROR");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const errors = validateConfig(config);
|
|
123
|
+
if (errors.length > 0) {
|
|
124
|
+
outputError(
|
|
125
|
+
`Invalid ide.yml in ${dir}. Run "tmux-ide validate" for details.`,
|
|
126
|
+
"INVALID_CONFIG",
|
|
127
|
+
);
|
|
145
128
|
}
|
|
146
129
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
let focusPane = paneMap[0][0];
|
|
150
|
-
const teammateCommands = [];
|
|
130
|
+
return config;
|
|
131
|
+
}
|
|
151
132
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// Per-pane directory for first panes of each row (created with project root)
|
|
164
|
-
if (p.dir && firstPanesOfRows.has(tmuxPane)) {
|
|
165
|
-
const paneDir = resolve(dir, p.dir);
|
|
166
|
-
tmux(`send-keys -t "${session}:0.${tmuxPane}" "cd ${paneDir}" C-m`);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// Environment variables
|
|
170
|
-
if (p.env && typeof p.env === "object") {
|
|
171
|
-
for (const [key, val] of Object.entries(p.env)) {
|
|
172
|
-
tmux(`send-keys -t "${session}:0.${tmuxPane}" "export ${key}=${val}" C-m`);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
const cmd = buildCommand(p);
|
|
177
|
-
if (cmd) {
|
|
178
|
-
if (team && p.role === "teammate") {
|
|
179
|
-
// Defer teammate launches to second pass
|
|
180
|
-
teammateCommands.push({ pane: tmuxPane, cmd });
|
|
181
|
-
} else {
|
|
182
|
-
tmux(`send-keys -t "${session}:0.${tmuxPane}" "${cmd}" C-m`);
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
if (p.focus) {
|
|
187
|
-
focusPane = tmuxPane;
|
|
188
|
-
}
|
|
189
|
-
}
|
|
133
|
+
function runBeforeHook(command, dir) {
|
|
134
|
+
if (!command) return;
|
|
135
|
+
|
|
136
|
+
console.log(`Running: ${command}`);
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
execSync(command, { cwd: dir, stdio: "inherit" });
|
|
140
|
+
} catch {
|
|
141
|
+
outputError(`The before hook failed: ${command}`, "BEFORE_HOOK_FAILED");
|
|
190
142
|
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function launch(targetDir, { json = false, attach = true } = {}) {
|
|
146
|
+
const dir = resolve(targetDir ?? ".");
|
|
147
|
+
const config = loadLaunchConfig(dir);
|
|
148
|
+
|
|
149
|
+
const { name: fallbackName } = getSessionName(dir);
|
|
150
|
+
const session = config.name ?? fallbackName;
|
|
151
|
+
const rows = config.rows;
|
|
152
|
+
const theme = config.theme ?? {};
|
|
153
|
+
const team = config.team ?? null;
|
|
154
|
+
|
|
155
|
+
runBeforeHook(config.before, dir);
|
|
156
|
+
|
|
157
|
+
// If session already exists, check for config drift and attach
|
|
158
|
+
if (hasSession(session)) {
|
|
159
|
+
const currentHash = configHash(config);
|
|
160
|
+
const storedHash = getSessionVariable(session, "@config_hash");
|
|
161
|
+
const configChanged = Boolean(storedHash && currentHash !== storedHash);
|
|
162
|
+
|
|
163
|
+
if (json) {
|
|
164
|
+
console.log(JSON.stringify({ session, running: true, configChanged }));
|
|
165
|
+
} else if (configChanged) {
|
|
166
|
+
console.log(`Session "${session}" is running but ide.yml has changed.`);
|
|
167
|
+
console.log(`Run "tmux-ide restart" to apply changes.`);
|
|
168
|
+
} else {
|
|
169
|
+
console.log(`Session "${session}" is already running. Attaching...`);
|
|
170
|
+
}
|
|
191
171
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
execSync("sleep 2");
|
|
195
|
-
for (const { pane: p, cmd } of teammateCommands) {
|
|
196
|
-
tmux(`send-keys -t "${session}:0.${p}" "${cmd}" C-m`);
|
|
172
|
+
if (attach) {
|
|
173
|
+
attachSession(session);
|
|
197
174
|
}
|
|
175
|
+
return;
|
|
198
176
|
}
|
|
199
177
|
|
|
200
|
-
//
|
|
201
|
-
const
|
|
202
|
-
const
|
|
203
|
-
const bg = theme.bg ?? "colour235";
|
|
204
|
-
const fg = theme.fg ?? "colour248";
|
|
178
|
+
// Get terminal dimensions
|
|
179
|
+
const cols = process.stdout.columns ?? 200;
|
|
180
|
+
const lines = process.stdout.rows ?? 50;
|
|
205
181
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
`set-option -t "${session}" pane-border-format " #{?pane_active,#[bold]▸,·} #T "`
|
|
209
|
-
);
|
|
210
|
-
tmux(`set-option -t "${session}" pane-border-style "fg=${border}"`);
|
|
211
|
-
tmux(`set-option -t "${session}" pane-active-border-style "fg=${accent}"`);
|
|
182
|
+
// Create session with first pane
|
|
183
|
+
const rootPaneId = createDetachedSession(session, dir, { cols, lines });
|
|
212
184
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
`set-option -t "${session}" window-status-current-format "#[fg=${accent},bold]●"`
|
|
185
|
+
// Set agent teams env var if team config is present
|
|
186
|
+
if (team) {
|
|
187
|
+
setSessionEnvironment(session, "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS", "1");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const { paneMap, firstPanesOfRows } = buildPaneMap(
|
|
191
|
+
rows,
|
|
192
|
+
dir,
|
|
193
|
+
rootPaneId,
|
|
194
|
+
({ targetPane, direction, cwd, percent }) => splitPane(targetPane, direction, cwd, percent),
|
|
224
195
|
);
|
|
225
|
-
|
|
226
|
-
|
|
196
|
+
|
|
197
|
+
const { focusPane, paneActions } = collectPaneStartupPlan(rows, paneMap, firstPanesOfRows, dir);
|
|
198
|
+
|
|
199
|
+
for (const action of paneActions) {
|
|
200
|
+
if (action.title) {
|
|
201
|
+
setPaneTitle(action.targetPane, action.title);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (action.chdir) {
|
|
205
|
+
sendLiteral(action.targetPane, `cd ${action.chdir}`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
for (const exportCommand of action.exports) {
|
|
209
|
+
sendLiteral(action.targetPane, exportCommand);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (action.command) {
|
|
213
|
+
sendLiteral(action.targetPane, action.command);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
for (const command of buildSessionOptions(session, { theme })) {
|
|
218
|
+
runSessionCommand(command);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Store config hash for drift detection on re-launch
|
|
222
|
+
setSessionVariable(session, "@config_hash", configHash(config));
|
|
223
|
+
|
|
224
|
+
// Start background session monitor (port detection + agent status)
|
|
225
|
+
const monitorScript = resolve(
|
|
226
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
227
|
+
"lib",
|
|
228
|
+
"session-monitor.js",
|
|
227
229
|
);
|
|
230
|
+
startSessionMonitor(session, monitorScript);
|
|
228
231
|
|
|
229
232
|
// Focus the correct pane
|
|
230
|
-
|
|
233
|
+
selectPane(focusPane);
|
|
231
234
|
|
|
232
235
|
// Launch summary
|
|
233
236
|
const totalPanes = rows.reduce((sum, r) => sum + (r.panes?.length ?? 0), 0);
|
|
234
|
-
console.log(
|
|
237
|
+
console.log(
|
|
238
|
+
`Starting "${session}" (${rows.length} row${rows.length === 1 ? "" : "s"}, ${totalPanes} pane${totalPanes === 1 ? "" : "s"})...`,
|
|
239
|
+
);
|
|
235
240
|
|
|
236
241
|
// Attach
|
|
237
|
-
|
|
242
|
+
if (attach) {
|
|
243
|
+
attachSession(session);
|
|
244
|
+
}
|
|
238
245
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export class IdeError extends Error {
|
|
2
|
+
constructor(message, { code, exitCode = 1, cause } = {}) {
|
|
3
|
+
super(message, { cause });
|
|
4
|
+
this.name = "IdeError";
|
|
5
|
+
this.code = code;
|
|
6
|
+
this.exitCode = exitCode;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
toJSON() {
|
|
10
|
+
const obj = { error: this.message, code: this.code };
|
|
11
|
+
if (this.cause) obj.cause = this.cause.message;
|
|
12
|
+
return obj;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class ConfigError extends IdeError {
|
|
17
|
+
constructor(message, code, { cause } = {}) {
|
|
18
|
+
super(message, { code, exitCode: 1, cause });
|
|
19
|
+
this.name = "ConfigError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class TmuxError extends IdeError {
|
|
24
|
+
constructor(message, code, { cause } = {}) {
|
|
25
|
+
super(message, { code, exitCode: 1, cause });
|
|
26
|
+
this.name = "TmuxError";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class SessionError extends IdeError {
|
|
31
|
+
constructor(message, code, { cause } = {}) {
|
|
32
|
+
super(message, { code, exitCode: 1, cause });
|
|
33
|
+
this.name = "SessionError";
|
|
34
|
+
}
|
|
35
|
+
}
|