tmux-ide 1.1.0 → 1.2.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 +10 -4
- package/package.json +1 -1
- package/skill/SKILL.md +35 -0
- package/src/attach.js +3 -5
- package/src/config.js +25 -30
- package/src/init.js +2 -8
- package/src/inspect.js +1 -1
- package/src/launch.js +48 -35
- package/src/lib/errors.js +35 -0
- package/src/lib/launch-plan.js +4 -38
- package/src/lib/output.js +14 -35
- package/src/lib/session-monitor.js +179 -0
- package/src/lib/session-options.js +95 -0
- package/src/lib/tmux.js +54 -17
- package/src/lib/yaml-io.js +2 -2
- package/src/restart.js +1 -1
- package/src/status.js +1 -1
- package/src/stop.js +7 -6
- package/src/validate.js +1 -1
package/bin/cli.js
CHANGED
|
@@ -13,7 +13,8 @@ import { validate } from "../src/validate.js";
|
|
|
13
13
|
import { detect } from "../src/detect.js";
|
|
14
14
|
import { config } from "../src/config.js";
|
|
15
15
|
import { restart } from "../src/restart.js";
|
|
16
|
-
import {
|
|
16
|
+
import { IdeError } from "../src/lib/errors.js";
|
|
17
|
+
import { printCommandError } from "../src/lib/output.js";
|
|
17
18
|
|
|
18
19
|
const { positionals, values } = parseArgs({
|
|
19
20
|
allowPositionals: true,
|
|
@@ -28,6 +29,7 @@ const { positionals, values } = parseArgs({
|
|
|
28
29
|
write: { type: "boolean" },
|
|
29
30
|
template: { type: "string" },
|
|
30
31
|
name: { type: "string" },
|
|
32
|
+
verbose: { type: "boolean", default: false },
|
|
31
33
|
help: { type: "boolean", short: "h" },
|
|
32
34
|
version: { type: "boolean", short: "v" },
|
|
33
35
|
},
|
|
@@ -57,6 +59,10 @@ if (values.version) {
|
|
|
57
59
|
process.exit(0);
|
|
58
60
|
}
|
|
59
61
|
|
|
62
|
+
if (values.verbose) {
|
|
63
|
+
globalThis.__tmuxIdeVerbose = true;
|
|
64
|
+
}
|
|
65
|
+
|
|
60
66
|
const firstPositional = positionals[0];
|
|
61
67
|
const hasKnownCommand = firstPositional ? knownCommands.has(firstPositional) : false;
|
|
62
68
|
const command = hasKnownCommand ? firstPositional : "start";
|
|
@@ -102,6 +108,7 @@ ${bold("Flags:")}
|
|
|
102
108
|
${cyan("--json")} ${dim("Output as JSON (all commands)")}
|
|
103
109
|
${cyan("--template <name>")} ${dim("Use specific template for init")}
|
|
104
110
|
${cyan("--write")} ${dim("Write detected config to ide.yml")}
|
|
111
|
+
${cyan("--verbose")} ${dim("Log all tmux commands (or set TMUX_IDE_DEBUG=1)")}
|
|
105
112
|
${cyan("-h, --help")} ${dim("Show usage")}
|
|
106
113
|
${cyan("-v, --version")} ${dim("Show version number")}`);
|
|
107
114
|
}
|
|
@@ -195,14 +202,13 @@ try {
|
|
|
195
202
|
break;
|
|
196
203
|
|
|
197
204
|
default:
|
|
198
|
-
throw new
|
|
205
|
+
throw new IdeError(`Unknown command: ${command}\nRun "tmux-ide help" for usage.`, {
|
|
199
206
|
code: "USAGE",
|
|
200
207
|
exitCode: 1,
|
|
201
|
-
json,
|
|
202
208
|
});
|
|
203
209
|
}
|
|
204
210
|
} catch (error) {
|
|
205
|
-
if (error instanceof
|
|
211
|
+
if (error instanceof IdeError) {
|
|
206
212
|
printCommandError(error, { json });
|
|
207
213
|
} else {
|
|
208
214
|
throw error;
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -164,6 +164,38 @@ tmux-ide config disable-team
|
|
|
164
164
|
|
|
165
165
|
Removes the `team` config and all `role`/`task` fields from panes.
|
|
166
166
|
|
|
167
|
+
## Session features (v1.2.0)
|
|
168
|
+
|
|
169
|
+
tmux-ide sessions include these built-in features:
|
|
170
|
+
|
|
171
|
+
### Mouse support
|
|
172
|
+
|
|
173
|
+
Mouse is enabled by default. Users can click to focus panes, scroll with trackpad, and drag pane borders to resize.
|
|
174
|
+
|
|
175
|
+
### Two-line status bar
|
|
176
|
+
|
|
177
|
+
```
|
|
178
|
+
Line 0: MY-PROJECT IDE ● 14:30 │ Mar 17
|
|
179
|
+
Line 1: ⏺ Claude 1 │ ● Claude 2 │ ⏺ Dev Server │ Shell
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
- Line 0: session name, window indicators, time/date
|
|
183
|
+
- Line 1: clickable pane tabs (click to switch panes)
|
|
184
|
+
- Green `⏺` next to panes with a running dev server (listening TCP port)
|
|
185
|
+
- Pulsing `⏺` next to panes where Claude/Codex is actively working
|
|
186
|
+
- Dim `●` next to panes where Claude/Codex is idle
|
|
187
|
+
|
|
188
|
+
### Config drift detection
|
|
189
|
+
|
|
190
|
+
If `ide.yml` is edited while a session is running, `tmux-ide` warns the user and suggests `tmux-ide restart` to apply changes.
|
|
191
|
+
|
|
192
|
+
### Debugging
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
tmux-ide --verbose # Log all tmux commands to stderr
|
|
196
|
+
TMUX_IDE_DEBUG=1 tmux-ide # Same via env var
|
|
197
|
+
```
|
|
198
|
+
|
|
167
199
|
## Programmatic CLI
|
|
168
200
|
|
|
169
201
|
All commands support `--json` for structured output.
|
|
@@ -200,6 +232,7 @@ tmux-ide stop # Kill session
|
|
|
200
232
|
tmux-ide restart # Stop and relaunch
|
|
201
233
|
tmux-ide attach # Reattach
|
|
202
234
|
tmux-ide init # Scaffold config
|
|
235
|
+
tmux-ide --verbose # Launch with tmux command tracing
|
|
203
236
|
```
|
|
204
237
|
|
|
205
238
|
## Modification workflow
|
|
@@ -207,6 +240,7 @@ tmux-ide init # Scaffold config
|
|
|
207
240
|
1. Read: `tmux-ide config --json`
|
|
208
241
|
2. Modify: `tmux-ide config set <path> <value>` or `add-pane`/`remove-pane`
|
|
209
242
|
3. Validate: `tmux-ide validate --json`
|
|
243
|
+
4. Apply: `tmux-ide restart` (needed if session is already running)
|
|
210
244
|
|
|
211
245
|
## Best practices
|
|
212
246
|
|
|
@@ -218,6 +252,7 @@ tmux-ide init # Scaffold config
|
|
|
218
252
|
- Use `detect --json` first to understand the project stack
|
|
219
253
|
- For agent teams: assign specific tasks to teammate panes so your prompts stay focused
|
|
220
254
|
- The team lead should have `focus: true` for easy access
|
|
255
|
+
- Use `tmux-ide --verbose` or `TMUX_IDE_DEBUG=1` when debugging layout issues
|
|
221
256
|
|
|
222
257
|
## ide.yml format
|
|
223
258
|
|
package/src/attach.js
CHANGED
|
@@ -3,15 +3,13 @@ import { getSessionName } from "./lib/yaml-io.js";
|
|
|
3
3
|
import { outputError } from "./lib/output.js";
|
|
4
4
|
import { attachSession, getSessionState } from "./lib/tmux.js";
|
|
5
5
|
|
|
6
|
-
export async function attach(targetDir, { json } = {}) {
|
|
6
|
+
export async function attach(targetDir, { json: _json } = {}) {
|
|
7
7
|
const dir = resolve(targetDir ?? ".");
|
|
8
|
-
const session = getSessionName(dir);
|
|
8
|
+
const { name: session } = getSessionName(dir);
|
|
9
9
|
const state = getSessionState(session);
|
|
10
10
|
|
|
11
11
|
if (!state.running) {
|
|
12
|
-
outputError(`Session "${session}" is not running. Start it with: tmux-ide`, "NOT_RUNNING"
|
|
13
|
-
json,
|
|
14
|
-
});
|
|
12
|
+
outputError(`Session "${session}" is not running. Start it with: tmux-ide`, "NOT_RUNNING");
|
|
15
13
|
return;
|
|
16
14
|
}
|
|
17
15
|
|
package/src/config.js
CHANGED
|
@@ -31,7 +31,7 @@ function dumpConfig(dir, { json }) {
|
|
|
31
31
|
try {
|
|
32
32
|
({ config: cfg } = readConfig(dir));
|
|
33
33
|
} catch (e) {
|
|
34
|
-
outputError(`Cannot read ide.yml: ${e.message}`, "READ_ERROR"
|
|
34
|
+
outputError(`Cannot read ide.yml: ${e.message}`, "READ_ERROR");
|
|
35
35
|
return;
|
|
36
36
|
}
|
|
37
37
|
|
|
@@ -46,7 +46,7 @@ function dumpConfig(dir, { json }) {
|
|
|
46
46
|
function setConfig(dir, args, { json }) {
|
|
47
47
|
const [dotpath, ...rest] = args;
|
|
48
48
|
if (!dotpath || rest.length === 0) {
|
|
49
|
-
outputError("Usage: tmux-ide config set <dotpath> <value>", "USAGE"
|
|
49
|
+
outputError("Usage: tmux-ide config set <dotpath> <value>", "USAGE");
|
|
50
50
|
return;
|
|
51
51
|
}
|
|
52
52
|
|
|
@@ -54,12 +54,12 @@ function setConfig(dir, args, { json }) {
|
|
|
54
54
|
try {
|
|
55
55
|
({ config: cfg } = readConfig(dir));
|
|
56
56
|
} catch (e) {
|
|
57
|
-
outputError(`Cannot read ide.yml: ${e.message}`, "READ_ERROR"
|
|
57
|
+
outputError(`Cannot read ide.yml: ${e.message}`, "READ_ERROR");
|
|
58
58
|
return;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
if (!isConfigObject(cfg)) {
|
|
62
|
-
outputError("Invalid ide.yml: config root must be an object", "INVALID_CONFIG"
|
|
62
|
+
outputError("Invalid ide.yml: config root must be an object", "INVALID_CONFIG");
|
|
63
63
|
return;
|
|
64
64
|
}
|
|
65
65
|
|
|
@@ -85,7 +85,6 @@ function addPane(dir, args, { json }) {
|
|
|
85
85
|
outputError(
|
|
86
86
|
"Usage: tmux-ide config add-pane --row <N> --title <T> [--command <C>] [--size <S>]",
|
|
87
87
|
"USAGE",
|
|
88
|
-
{ json },
|
|
89
88
|
);
|
|
90
89
|
return;
|
|
91
90
|
}
|
|
@@ -94,30 +93,28 @@ function addPane(dir, args, { json }) {
|
|
|
94
93
|
try {
|
|
95
94
|
({ config: cfg } = readConfig(dir));
|
|
96
95
|
} catch (e) {
|
|
97
|
-
outputError(`Cannot read ide.yml: ${e.message}`, "READ_ERROR"
|
|
96
|
+
outputError(`Cannot read ide.yml: ${e.message}`, "READ_ERROR");
|
|
98
97
|
return;
|
|
99
98
|
}
|
|
100
99
|
|
|
101
100
|
if (!Array.isArray(cfg?.rows)) {
|
|
102
|
-
outputError("Invalid ide.yml: 'rows' must be an array", "INVALID_CONFIG"
|
|
101
|
+
outputError("Invalid ide.yml: 'rows' must be an array", "INVALID_CONFIG");
|
|
103
102
|
return;
|
|
104
103
|
}
|
|
105
104
|
|
|
106
105
|
const rowIdx = parseIndex(row);
|
|
107
106
|
if (rowIdx == null) {
|
|
108
|
-
outputError(`Invalid row index "${row}"`, "USAGE"
|
|
107
|
+
outputError(`Invalid row index "${row}"`, "USAGE");
|
|
109
108
|
return;
|
|
110
109
|
}
|
|
111
110
|
|
|
112
111
|
if (!cfg.rows[rowIdx]) {
|
|
113
|
-
outputError(`Row ${rowIdx} does not exist`, "INVALID_ROW"
|
|
112
|
+
outputError(`Row ${rowIdx} does not exist`, "INVALID_ROW");
|
|
114
113
|
return;
|
|
115
114
|
}
|
|
116
115
|
|
|
117
116
|
if (!Array.isArray(cfg.rows[rowIdx].panes)) {
|
|
118
|
-
outputError(`Invalid ide.yml: row ${rowIdx} panes must be an array`, "INVALID_CONFIG"
|
|
119
|
-
json,
|
|
120
|
-
});
|
|
117
|
+
outputError(`Invalid ide.yml: row ${rowIdx} panes must be an array`, "INVALID_CONFIG");
|
|
121
118
|
return;
|
|
122
119
|
}
|
|
123
120
|
|
|
@@ -139,7 +136,7 @@ function addPane(dir, args, { json }) {
|
|
|
139
136
|
function removePane(dir, args, { json }) {
|
|
140
137
|
const { row, pane } = parseNamedArgs(args);
|
|
141
138
|
if (row === undefined || pane === undefined) {
|
|
142
|
-
outputError("Usage: tmux-ide config remove-pane --row <N> --pane <M>", "USAGE"
|
|
139
|
+
outputError("Usage: tmux-ide config remove-pane --row <N> --pane <M>", "USAGE");
|
|
143
140
|
return;
|
|
144
141
|
}
|
|
145
142
|
|
|
@@ -147,31 +144,29 @@ function removePane(dir, args, { json }) {
|
|
|
147
144
|
try {
|
|
148
145
|
({ config: cfg } = readConfig(dir));
|
|
149
146
|
} catch (e) {
|
|
150
|
-
outputError(`Cannot read ide.yml: ${e.message}`, "READ_ERROR"
|
|
147
|
+
outputError(`Cannot read ide.yml: ${e.message}`, "READ_ERROR");
|
|
151
148
|
return;
|
|
152
149
|
}
|
|
153
150
|
|
|
154
151
|
if (!Array.isArray(cfg?.rows)) {
|
|
155
|
-
outputError("Invalid ide.yml: 'rows' must be an array", "INVALID_CONFIG"
|
|
152
|
+
outputError("Invalid ide.yml: 'rows' must be an array", "INVALID_CONFIG");
|
|
156
153
|
return;
|
|
157
154
|
}
|
|
158
155
|
|
|
159
156
|
const rowIdx = parseIndex(row);
|
|
160
157
|
const paneIdx = parseIndex(pane);
|
|
161
158
|
if (rowIdx == null || paneIdx == null) {
|
|
162
|
-
outputError("Usage: tmux-ide config remove-pane --row <N> --pane <M>", "USAGE"
|
|
159
|
+
outputError("Usage: tmux-ide config remove-pane --row <N> --pane <M>", "USAGE");
|
|
163
160
|
return;
|
|
164
161
|
}
|
|
165
162
|
|
|
166
163
|
if (!Array.isArray(cfg.rows[rowIdx]?.panes)) {
|
|
167
|
-
outputError(`Invalid ide.yml: row ${rowIdx} panes must be an array`, "INVALID_CONFIG"
|
|
168
|
-
json,
|
|
169
|
-
});
|
|
164
|
+
outputError(`Invalid ide.yml: row ${rowIdx} panes must be an array`, "INVALID_CONFIG");
|
|
170
165
|
return;
|
|
171
166
|
}
|
|
172
167
|
|
|
173
168
|
if (!cfg.rows[rowIdx].panes[paneIdx]) {
|
|
174
|
-
outputError(`Pane ${paneIdx} in row ${rowIdx} does not exist`, "INVALID_PANE"
|
|
169
|
+
outputError(`Pane ${paneIdx} in row ${rowIdx} does not exist`, "INVALID_PANE");
|
|
175
170
|
return;
|
|
176
171
|
}
|
|
177
172
|
|
|
@@ -192,17 +187,17 @@ function addRow(dir, args, { json }) {
|
|
|
192
187
|
try {
|
|
193
188
|
({ config: cfg } = readConfig(dir));
|
|
194
189
|
} catch (e) {
|
|
195
|
-
outputError(`Cannot read ide.yml: ${e.message}`, "READ_ERROR"
|
|
190
|
+
outputError(`Cannot read ide.yml: ${e.message}`, "READ_ERROR");
|
|
196
191
|
return;
|
|
197
192
|
}
|
|
198
193
|
|
|
199
194
|
if (!isConfigObject(cfg)) {
|
|
200
|
-
outputError("Invalid ide.yml: config root must be an object", "INVALID_CONFIG"
|
|
195
|
+
outputError("Invalid ide.yml: config root must be an object", "INVALID_CONFIG");
|
|
201
196
|
return;
|
|
202
197
|
}
|
|
203
198
|
|
|
204
199
|
if (cfg.rows !== undefined && !Array.isArray(cfg.rows)) {
|
|
205
|
-
outputError("Invalid ide.yml: 'rows' must be an array", "INVALID_CONFIG"
|
|
200
|
+
outputError("Invalid ide.yml: 'rows' must be an array", "INVALID_CONFIG");
|
|
206
201
|
return;
|
|
207
202
|
}
|
|
208
203
|
|
|
@@ -228,17 +223,17 @@ function enableTeam(dir, args, { json }) {
|
|
|
228
223
|
try {
|
|
229
224
|
({ config: cfg } = readConfig(dir));
|
|
230
225
|
} catch (e) {
|
|
231
|
-
outputError(`Cannot read ide.yml: ${e.message}`, "READ_ERROR"
|
|
226
|
+
outputError(`Cannot read ide.yml: ${e.message}`, "READ_ERROR");
|
|
232
227
|
return;
|
|
233
228
|
}
|
|
234
229
|
|
|
235
230
|
if (!isConfigObject(cfg)) {
|
|
236
|
-
outputError("Invalid ide.yml: config root must be an object", "INVALID_CONFIG"
|
|
231
|
+
outputError("Invalid ide.yml: config root must be an object", "INVALID_CONFIG");
|
|
237
232
|
return;
|
|
238
233
|
}
|
|
239
234
|
|
|
240
235
|
if (cfg.rows !== undefined && !Array.isArray(cfg.rows)) {
|
|
241
|
-
outputError("Invalid ide.yml: 'rows' must be an array", "INVALID_CONFIG"
|
|
236
|
+
outputError("Invalid ide.yml: 'rows' must be an array", "INVALID_CONFIG");
|
|
242
237
|
return;
|
|
243
238
|
}
|
|
244
239
|
|
|
@@ -261,7 +256,7 @@ function enableTeam(dir, args, { json }) {
|
|
|
261
256
|
}
|
|
262
257
|
if (!leadAssigned) {
|
|
263
258
|
delete cfg.team;
|
|
264
|
-
outputError("Cannot enable agent team: no Claude panes found", "INVALID_CONFIG"
|
|
259
|
+
outputError("Cannot enable agent team: no Claude panes found", "INVALID_CONFIG");
|
|
265
260
|
return;
|
|
266
261
|
}
|
|
267
262
|
|
|
@@ -279,17 +274,17 @@ function disableTeam(dir, { json }) {
|
|
|
279
274
|
try {
|
|
280
275
|
({ config: cfg } = readConfig(dir));
|
|
281
276
|
} catch (e) {
|
|
282
|
-
outputError(`Cannot read ide.yml: ${e.message}`, "READ_ERROR"
|
|
277
|
+
outputError(`Cannot read ide.yml: ${e.message}`, "READ_ERROR");
|
|
283
278
|
return;
|
|
284
279
|
}
|
|
285
280
|
|
|
286
281
|
if (!isConfigObject(cfg)) {
|
|
287
|
-
outputError("Invalid ide.yml: config root must be an object", "INVALID_CONFIG"
|
|
282
|
+
outputError("Invalid ide.yml: config root must be an object", "INVALID_CONFIG");
|
|
288
283
|
return;
|
|
289
284
|
}
|
|
290
285
|
|
|
291
286
|
if (cfg.rows !== undefined && !Array.isArray(cfg.rows)) {
|
|
292
|
-
outputError("Invalid ide.yml: 'rows' must be an array", "INVALID_CONFIG"
|
|
287
|
+
outputError("Invalid ide.yml: 'rows' must be an array", "INVALID_CONFIG");
|
|
293
288
|
return;
|
|
294
289
|
}
|
|
295
290
|
|
package/src/init.js
CHANGED
|
@@ -11,20 +11,14 @@ export async function init({ template, json } = {}) {
|
|
|
11
11
|
const configPath = resolve(dir, "ide.yml");
|
|
12
12
|
|
|
13
13
|
if (existsSync(configPath)) {
|
|
14
|
-
outputError("ide.yml already exists in this directory", "EXISTS"
|
|
14
|
+
outputError("ide.yml already exists in this directory", "EXISTS");
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
// If a specific template is requested, use it
|
|
18
18
|
if (template) {
|
|
19
19
|
const templatePath = resolve(__dirname, "..", "templates", `${template}.yml`);
|
|
20
20
|
if (!existsSync(templatePath)) {
|
|
21
|
-
outputError(
|
|
22
|
-
json
|
|
23
|
-
? `Template "${template}" not found`
|
|
24
|
-
: `Template "${template}" not found. Available: default, nextjs, convex, vite, python, go, agent-team, agent-team-nextjs, agent-team-monorepo`,
|
|
25
|
-
"NOT_FOUND",
|
|
26
|
-
{ json },
|
|
27
|
-
);
|
|
21
|
+
outputError(`Template "${template}" not found`, "NOT_FOUND");
|
|
28
22
|
}
|
|
29
23
|
|
|
30
24
|
let content = readFileSync(templatePath, "utf-8");
|
package/src/inspect.js
CHANGED
|
@@ -67,7 +67,7 @@ export async function inspect(targetDir, { json } = {}) {
|
|
|
67
67
|
try {
|
|
68
68
|
({ config, configPath } = readConfig(dir));
|
|
69
69
|
} catch (error) {
|
|
70
|
-
outputError(`Cannot read ide.yml: ${error.message}`, "READ_ERROR"
|
|
70
|
+
outputError(`Cannot read ide.yml: ${error.message}`, "READ_ERROR");
|
|
71
71
|
return;
|
|
72
72
|
}
|
|
73
73
|
|
package/src/launch.js
CHANGED
|
@@ -1,20 +1,26 @@
|
|
|
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
7
|
import { outputError } from "./lib/output.js";
|
|
6
|
-
import {
|
|
8
|
+
import { collectPaneStartupPlan } from "./lib/launch-plan.js";
|
|
9
|
+
import { buildSessionOptions } from "./lib/session-options.js";
|
|
7
10
|
import {
|
|
8
11
|
attachSession,
|
|
9
12
|
createDetachedSession,
|
|
10
13
|
getPaneCurrentCommand,
|
|
14
|
+
getSessionVariable,
|
|
11
15
|
hasSession,
|
|
12
16
|
runSessionCommand,
|
|
13
17
|
selectPane,
|
|
14
18
|
sendLiteral,
|
|
15
19
|
setPaneTitle,
|
|
16
20
|
setSessionEnvironment,
|
|
21
|
+
setSessionVariable,
|
|
17
22
|
splitPane,
|
|
23
|
+
startSessionMonitor,
|
|
18
24
|
} from "./lib/tmux.js";
|
|
19
25
|
import { validateConfig } from "./validate.js";
|
|
20
26
|
|
|
@@ -22,6 +28,10 @@ function sleepMs(ms) {
|
|
|
22
28
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
23
29
|
}
|
|
24
30
|
|
|
31
|
+
function configHash(config) {
|
|
32
|
+
return createHash("sha256").update(JSON.stringify(config)).digest("hex").slice(0, 12);
|
|
33
|
+
}
|
|
34
|
+
|
|
25
35
|
export function waitForPaneCommand(
|
|
26
36
|
targetPane,
|
|
27
37
|
expectedCommands,
|
|
@@ -93,7 +103,7 @@ export function buildPaneMap(rows, dir, rootPaneId, splitPane) {
|
|
|
93
103
|
return { paneMap, firstPanesOfRows };
|
|
94
104
|
}
|
|
95
105
|
|
|
96
|
-
function loadLaunchConfig(dir
|
|
106
|
+
function loadLaunchConfig(dir) {
|
|
97
107
|
let config;
|
|
98
108
|
|
|
99
109
|
try {
|
|
@@ -103,11 +113,10 @@ function loadLaunchConfig(dir, { json } = {}) {
|
|
|
103
113
|
outputError(
|
|
104
114
|
`No ide.yml found in ${dir}. Run "tmux-ide init" or "tmux-ide detect --write" to create one.`,
|
|
105
115
|
"CONFIG_NOT_FOUND",
|
|
106
|
-
{ json },
|
|
107
116
|
);
|
|
108
117
|
}
|
|
109
118
|
|
|
110
|
-
outputError(`Cannot read ide.yml: ${error.message}`, "READ_ERROR"
|
|
119
|
+
outputError(`Cannot read ide.yml: ${error.message}`, "READ_ERROR");
|
|
111
120
|
}
|
|
112
121
|
|
|
113
122
|
const errors = validateConfig(config);
|
|
@@ -115,14 +124,13 @@ function loadLaunchConfig(dir, { json } = {}) {
|
|
|
115
124
|
outputError(
|
|
116
125
|
`Invalid ide.yml in ${dir}. Run "tmux-ide validate" for details.`,
|
|
117
126
|
"INVALID_CONFIG",
|
|
118
|
-
{ json },
|
|
119
127
|
);
|
|
120
128
|
}
|
|
121
129
|
|
|
122
130
|
return config;
|
|
123
131
|
}
|
|
124
132
|
|
|
125
|
-
function runBeforeHook(command, dir
|
|
133
|
+
function runBeforeHook(command, dir) {
|
|
126
134
|
if (!command) return;
|
|
127
135
|
|
|
128
136
|
console.log(`Running: ${command}`);
|
|
@@ -130,27 +138,37 @@ function runBeforeHook(command, dir, { json } = {}) {
|
|
|
130
138
|
try {
|
|
131
139
|
execSync(command, { cwd: dir, stdio: "inherit" });
|
|
132
140
|
} catch {
|
|
133
|
-
|
|
134
|
-
? `The "before" hook failed: ${command}`
|
|
135
|
-
: `The before hook failed: ${command}`;
|
|
136
|
-
outputError(message, "BEFORE_HOOK_FAILED", { json });
|
|
141
|
+
outputError(`The before hook failed: ${command}`, "BEFORE_HOOK_FAILED");
|
|
137
142
|
}
|
|
138
143
|
}
|
|
139
144
|
|
|
140
|
-
export async function launch(targetDir, { json, attach = true } = {}) {
|
|
145
|
+
export async function launch(targetDir, { json = false, attach = true } = {}) {
|
|
141
146
|
const dir = resolve(targetDir ?? ".");
|
|
142
|
-
const config = loadLaunchConfig(dir
|
|
147
|
+
const config = loadLaunchConfig(dir);
|
|
143
148
|
|
|
144
|
-
const
|
|
149
|
+
const { name: fallbackName } = getSessionName(dir);
|
|
150
|
+
const session = config.name ?? fallbackName;
|
|
145
151
|
const rows = config.rows;
|
|
146
152
|
const theme = config.theme ?? {};
|
|
147
153
|
const team = config.team ?? null;
|
|
148
154
|
|
|
149
|
-
runBeforeHook(config.before, dir
|
|
155
|
+
runBeforeHook(config.before, dir);
|
|
150
156
|
|
|
151
|
-
// If session already exists,
|
|
157
|
+
// If session already exists, check for config drift and attach
|
|
152
158
|
if (hasSession(session)) {
|
|
153
|
-
|
|
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
|
+
}
|
|
171
|
+
|
|
154
172
|
if (attach) {
|
|
155
173
|
attachSession(session);
|
|
156
174
|
}
|
|
@@ -176,13 +194,7 @@ export async function launch(targetDir, { json, attach = true } = {}) {
|
|
|
176
194
|
({ targetPane, direction, cwd, percent }) => splitPane(targetPane, direction, cwd, percent),
|
|
177
195
|
);
|
|
178
196
|
|
|
179
|
-
const { focusPane,
|
|
180
|
-
rows,
|
|
181
|
-
paneMap,
|
|
182
|
-
firstPanesOfRows,
|
|
183
|
-
dir,
|
|
184
|
-
team,
|
|
185
|
-
);
|
|
197
|
+
const { focusPane, paneActions } = collectPaneStartupPlan(rows, paneMap, firstPanesOfRows, dir);
|
|
186
198
|
|
|
187
199
|
for (const action of paneActions) {
|
|
188
200
|
if (action.title) {
|
|
@@ -202,20 +214,21 @@ export async function launch(targetDir, { json, attach = true } = {}) {
|
|
|
202
214
|
}
|
|
203
215
|
}
|
|
204
216
|
|
|
205
|
-
|
|
206
|
-
if (teammateCommands.length > 0) {
|
|
207
|
-
if (leadPane) {
|
|
208
|
-
waitForPaneCommand(leadPane, ["claude"]);
|
|
209
|
-
}
|
|
210
|
-
for (const { pane: p, cmd } of teammateCommands) {
|
|
211
|
-
sendLiteral(p, cmd);
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
for (const command of buildThemeOptions(session, theme)) {
|
|
217
|
+
for (const command of buildSessionOptions(session, { theme })) {
|
|
216
218
|
runSessionCommand(command);
|
|
217
219
|
}
|
|
218
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",
|
|
229
|
+
);
|
|
230
|
+
startSessionMonitor(session, monitorScript);
|
|
231
|
+
|
|
219
232
|
// Focus the correct pane
|
|
220
233
|
selectPane(focusPane);
|
|
221
234
|
|
|
@@ -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
|
+
}
|
package/src/lib/launch-plan.js
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
|
|
3
|
-
export function buildPaneCommand(pane
|
|
3
|
+
export function buildPaneCommand(pane) {
|
|
4
4
|
if (!pane.command) return null;
|
|
5
5
|
return pane.command;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
-
export function collectPaneStartupPlan(rows, paneMap, firstPanesOfRows, dir
|
|
8
|
+
export function collectPaneStartupPlan(rows, paneMap, firstPanesOfRows, dir) {
|
|
9
9
|
let focusPane = paneMap[0][0];
|
|
10
|
-
const teammateCommands = [];
|
|
11
10
|
const paneActions = [];
|
|
12
11
|
|
|
13
12
|
for (let rowIdx = 0; rowIdx < rows.length; rowIdx++) {
|
|
@@ -33,7 +32,7 @@ export function collectPaneStartupPlan(rows, paneMap, firstPanesOfRows, dir, tea
|
|
|
33
32
|
action.exports = Object.entries(pane.env).map(([key, value]) => `export ${key}=${value}`);
|
|
34
33
|
}
|
|
35
34
|
|
|
36
|
-
const command = buildPaneCommand(pane
|
|
35
|
+
const command = buildPaneCommand(pane);
|
|
37
36
|
if (command) {
|
|
38
37
|
action.command = command;
|
|
39
38
|
}
|
|
@@ -46,38 +45,5 @@ export function collectPaneStartupPlan(rows, paneMap, firstPanesOfRows, dir, tea
|
|
|
46
45
|
}
|
|
47
46
|
}
|
|
48
47
|
|
|
49
|
-
return { focusPane,
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export function buildThemeOptions(session, theme = {}) {
|
|
53
|
-
const accent = theme.accent ?? "colour75";
|
|
54
|
-
const border = theme.border ?? "colour238";
|
|
55
|
-
const bg = theme.bg ?? "colour235";
|
|
56
|
-
const fg = theme.fg ?? "colour248";
|
|
57
|
-
|
|
58
|
-
return [
|
|
59
|
-
["set-option", "-t", session, "pane-border-status", "top"],
|
|
60
|
-
["set-option", "-t", session, "pane-border-format", " #{?pane_active,#[bold]▸,·} #T "],
|
|
61
|
-
["set-option", "-t", session, "pane-border-style", `fg=${border}`],
|
|
62
|
-
["set-option", "-t", session, "pane-active-border-style", `fg=${accent}`],
|
|
63
|
-
["set-option", "-t", session, "status-style", `bg=${bg},fg=${fg}`],
|
|
64
|
-
[
|
|
65
|
-
"set-option",
|
|
66
|
-
"-t",
|
|
67
|
-
session,
|
|
68
|
-
"status-left",
|
|
69
|
-
`#[fg=colour0,bg=${accent},bold] ${session.toUpperCase()} IDE #[default] `,
|
|
70
|
-
],
|
|
71
|
-
["set-option", "-t", session, "status-left-length", "30"],
|
|
72
|
-
[
|
|
73
|
-
"set-option",
|
|
74
|
-
"-t",
|
|
75
|
-
session,
|
|
76
|
-
"status-right",
|
|
77
|
-
`#[fg=colour243]%H:%M #[fg=${accent}]│ #[fg=${fg}]%b %d `,
|
|
78
|
-
],
|
|
79
|
-
["set-option", "-t", session, "status-justify", "centre"],
|
|
80
|
-
["set-option", "-t", session, "window-status-current-format", `#[fg=${accent},bold]●`],
|
|
81
|
-
["set-option", "-t", session, "window-status-format", `#[fg=${border}]○`],
|
|
82
|
-
];
|
|
48
|
+
return { focusPane, paneActions };
|
|
83
49
|
}
|
package/src/lib/output.js
CHANGED
|
@@ -1,23 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
if (json) {
|
|
3
|
-
console.log(JSON.stringify(data, null, 2));
|
|
4
|
-
} else if (typeof data === "string") {
|
|
5
|
-
console.log(data);
|
|
6
|
-
} else {
|
|
7
|
-
console.log(data);
|
|
8
|
-
}
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export class CommandError extends Error {
|
|
12
|
-
constructor(message, { code, exitCode = 1, details, json = false } = {}) {
|
|
13
|
-
super(message);
|
|
14
|
-
this.name = "CommandError";
|
|
15
|
-
this.code = code;
|
|
16
|
-
this.exitCode = exitCode;
|
|
17
|
-
this.details = details;
|
|
18
|
-
this.json = json;
|
|
19
|
-
}
|
|
20
|
-
}
|
|
1
|
+
import { IdeError } from "./errors.js";
|
|
21
2
|
|
|
22
3
|
export function printLayout(config) {
|
|
23
4
|
const INNER = 40;
|
|
@@ -37,51 +18,49 @@ export function printLayout(config) {
|
|
|
37
18
|
|
|
38
19
|
// Top border or mid divider
|
|
39
20
|
if (r === 0) {
|
|
40
|
-
let top = "
|
|
21
|
+
let top = " \u250c";
|
|
41
22
|
for (let i = 0; i < count; i++) {
|
|
42
|
-
top += "
|
|
43
|
-
top += i < count - 1 ? "
|
|
23
|
+
top += "\u2500".repeat(widths[i]);
|
|
24
|
+
top += i < count - 1 ? "\u252c" : "\u2510";
|
|
44
25
|
}
|
|
45
26
|
console.log(top);
|
|
46
27
|
} else {
|
|
47
|
-
console.log("
|
|
28
|
+
console.log(" \u251c" + "\u2500".repeat(INNER + count - 1) + "\u2524");
|
|
48
29
|
}
|
|
49
30
|
|
|
50
31
|
// Content line
|
|
51
32
|
const sizeLabel = rows[r].size ?? "";
|
|
52
|
-
let line = "
|
|
33
|
+
let line = " \u2502";
|
|
53
34
|
for (let i = 0; i < count; i++) {
|
|
54
35
|
const title = panes[i]?.title ?? "";
|
|
55
36
|
const w = widths[i];
|
|
56
37
|
const pad = Math.max(0, w - title.length);
|
|
57
38
|
const left = Math.floor(pad / 2);
|
|
58
39
|
const right = pad - left;
|
|
59
|
-
line += " ".repeat(left) + title + " ".repeat(right) + "
|
|
40
|
+
line += " ".repeat(left) + title + " ".repeat(right) + "\u2502";
|
|
60
41
|
}
|
|
61
42
|
if (sizeLabel) line += " " + sizeLabel;
|
|
62
43
|
console.log(line);
|
|
63
44
|
|
|
64
45
|
// Bottom border (last row only)
|
|
65
46
|
if (r === rows.length - 1) {
|
|
66
|
-
let bot = "
|
|
47
|
+
let bot = " \u2514";
|
|
67
48
|
for (let i = 0; i < count; i++) {
|
|
68
|
-
bot += "
|
|
69
|
-
bot += i < count - 1 ? "
|
|
49
|
+
bot += "\u2500".repeat(widths[i]);
|
|
50
|
+
bot += i < count - 1 ? "\u2534" : "\u2518";
|
|
70
51
|
}
|
|
71
52
|
console.log(bot);
|
|
72
53
|
}
|
|
73
54
|
}
|
|
74
55
|
}
|
|
75
56
|
|
|
76
|
-
export function outputError(message, code, {
|
|
77
|
-
throw new
|
|
57
|
+
export function outputError(message, code, { exitCode = 1 } = {}) {
|
|
58
|
+
throw new IdeError(message, { code, exitCode });
|
|
78
59
|
}
|
|
79
60
|
|
|
80
|
-
export function printCommandError(error, { json =
|
|
61
|
+
export function printCommandError(error, { json = false } = {}) {
|
|
81
62
|
if (json) {
|
|
82
|
-
|
|
83
|
-
if (error.details !== undefined) payload.details = error.details;
|
|
84
|
-
console.error(JSON.stringify(payload, null, 2));
|
|
63
|
+
console.error(JSON.stringify(error.toJSON(), null, 2));
|
|
85
64
|
} else {
|
|
86
65
|
console.error(error.message);
|
|
87
66
|
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
const INTERVAL = 1000;
|
|
6
|
+
const SPINNERS = /^[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏⠂⠒⠢⠆⠐⠠⠄◐◓◑◒|/\\-] /;
|
|
7
|
+
|
|
8
|
+
// --- Port detection (pure helpers) ---
|
|
9
|
+
|
|
10
|
+
function getListeningPids() {
|
|
11
|
+
// Returns Set of PIDs that have a listening TCP port in range 1024-20000
|
|
12
|
+
try {
|
|
13
|
+
const raw = execFileSync("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN", "-FpPn"], {
|
|
14
|
+
encoding: "utf-8",
|
|
15
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
16
|
+
});
|
|
17
|
+
const pids = new Set();
|
|
18
|
+
let currentPid = null;
|
|
19
|
+
for (const line of raw.split("\n")) {
|
|
20
|
+
if (line.startsWith("p")) {
|
|
21
|
+
currentPid = line.slice(1);
|
|
22
|
+
} else if (line.startsWith("n") && currentPid) {
|
|
23
|
+
const match = line.match(/:(\d+)$/);
|
|
24
|
+
if (match) {
|
|
25
|
+
const port = parseInt(match[1], 10);
|
|
26
|
+
if (port >= 1024 && port <= 20000) pids.add(currentPid);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return pids;
|
|
31
|
+
} catch {
|
|
32
|
+
return new Set();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function getProcessTree() {
|
|
37
|
+
// Returns Map<pid, ppid>
|
|
38
|
+
try {
|
|
39
|
+
const raw = execFileSync("ps", ["-axo", "pid=,ppid="], {
|
|
40
|
+
encoding: "utf-8",
|
|
41
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
42
|
+
});
|
|
43
|
+
const tree = new Map();
|
|
44
|
+
for (const line of raw.trim().split("\n")) {
|
|
45
|
+
const parts = line.trim().split(/\s+/);
|
|
46
|
+
if (parts.length === 2) tree.set(parts[0], parts[1]);
|
|
47
|
+
}
|
|
48
|
+
return tree;
|
|
49
|
+
} catch {
|
|
50
|
+
return new Map();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function computePortPanes(panes, { listeners, tree } = {}) {
|
|
55
|
+
// Walk up from each listening PID to find which pane owns it
|
|
56
|
+
if (!listeners) listeners = getListeningPids();
|
|
57
|
+
if (!tree) tree = getProcessTree();
|
|
58
|
+
if (listeners.size === 0) return new Set();
|
|
59
|
+
|
|
60
|
+
const panePids = new Map(panes.map((p) => [p.pid, p.id]));
|
|
61
|
+
const result = new Set();
|
|
62
|
+
|
|
63
|
+
for (const listenerPid of listeners) {
|
|
64
|
+
let pid = listenerPid;
|
|
65
|
+
while (pid && pid !== "0") {
|
|
66
|
+
if (panePids.has(pid)) {
|
|
67
|
+
result.add(panePids.get(pid));
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
pid = tree.get(pid);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// --- Agent detection ---
|
|
77
|
+
|
|
78
|
+
export function computeAgentStates(panes) {
|
|
79
|
+
// Returns Map<paneId, "busy" | "idle" | null>
|
|
80
|
+
const states = new Map();
|
|
81
|
+
for (const pane of panes) {
|
|
82
|
+
const cmd = (pane.cmd ?? "").toLowerCase();
|
|
83
|
+
if (!cmd.includes("claude") && !cmd.includes("codex")) {
|
|
84
|
+
states.set(pane.id, null);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
states.set(pane.id, SPINNERS.test(pane.title ?? "") ? "busy" : "idle");
|
|
88
|
+
}
|
|
89
|
+
return states;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// --- Main loop (only runs when executed directly) ---
|
|
93
|
+
|
|
94
|
+
const isMainModule = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
95
|
+
|
|
96
|
+
if (isMainModule) {
|
|
97
|
+
const session = process.argv[2];
|
|
98
|
+
if (!session) process.exit(1);
|
|
99
|
+
|
|
100
|
+
function tmux(...args) {
|
|
101
|
+
return execFileSync("tmux", args, { encoding: "utf-8" }).trim();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function tmuxSilent(...args) {
|
|
105
|
+
try {
|
|
106
|
+
return tmux(...args);
|
|
107
|
+
} catch {
|
|
108
|
+
return "";
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function sessionExists() {
|
|
113
|
+
try {
|
|
114
|
+
tmux("has-session", "-t", session);
|
|
115
|
+
return true;
|
|
116
|
+
} catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function hasClients() {
|
|
122
|
+
return tmuxSilent("list-clients").length > 0;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function listPanes() {
|
|
126
|
+
const raw = tmuxSilent(
|
|
127
|
+
"list-panes",
|
|
128
|
+
"-t",
|
|
129
|
+
session,
|
|
130
|
+
"-F",
|
|
131
|
+
"#{pane_id}\t#{pane_pid}\t#{pane_current_command}\t#{pane_title}",
|
|
132
|
+
);
|
|
133
|
+
if (!raw) return [];
|
|
134
|
+
return raw.split("\n").map((line) => {
|
|
135
|
+
const [id, pid, cmd, title] = line.split("\t");
|
|
136
|
+
return { id, pid, cmd, title };
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let lastState = "";
|
|
141
|
+
|
|
142
|
+
function tick() {
|
|
143
|
+
if (!sessionExists()) process.exit(0);
|
|
144
|
+
if (!hasClients()) return; // skip when nobody is watching
|
|
145
|
+
|
|
146
|
+
const panes = listPanes();
|
|
147
|
+
if (panes.length === 0) return;
|
|
148
|
+
|
|
149
|
+
const portPanes = computePortPanes(panes);
|
|
150
|
+
const agentStates = computeAgentStates(panes);
|
|
151
|
+
|
|
152
|
+
// Build state fingerprint for change detection
|
|
153
|
+
const stateKey = panes
|
|
154
|
+
.map((p) => {
|
|
155
|
+
const port = portPanes.has(p.id) ? "1" : "0";
|
|
156
|
+
const agent = agentStates.get(p.id) ?? "-";
|
|
157
|
+
return `${p.id}:${port}:${agent}`;
|
|
158
|
+
})
|
|
159
|
+
.join("|");
|
|
160
|
+
|
|
161
|
+
if (stateKey === lastState) return;
|
|
162
|
+
|
|
163
|
+
// Apply changes
|
|
164
|
+
for (const pane of panes) {
|
|
165
|
+
const hasPort = portPanes.has(pane.id) ? "1" : "0";
|
|
166
|
+
const agent = agentStates.get(pane.id);
|
|
167
|
+
|
|
168
|
+
tmuxSilent("set-option", "-pqt", pane.id, "@has_port", hasPort);
|
|
169
|
+
tmuxSilent("set-option", "-pqt", pane.id, "@agent_busy", agent === "busy" ? "1" : "0");
|
|
170
|
+
tmuxSilent("set-option", "-pqt", pane.id, "@agent_idle", agent === "idle" ? "1" : "0");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
tmuxSilent("refresh-client", "-S");
|
|
174
|
+
lastState = stateKey;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
setInterval(tick, INTERVAL);
|
|
178
|
+
tick(); // run immediately
|
|
179
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composable builders for tmux session configuration.
|
|
3
|
+
* Each returns an array of tmux command arrays.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export function buildSessionOptions(session, { theme = {} } = {}) {
|
|
7
|
+
return [
|
|
8
|
+
...themeOptions(session, theme),
|
|
9
|
+
...borderOptions(session, theme),
|
|
10
|
+
...behaviorOptions(session),
|
|
11
|
+
...statusBarOptions(session, theme),
|
|
12
|
+
...keyBindings(),
|
|
13
|
+
];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function themeOptions(session, theme) {
|
|
17
|
+
const accent = theme.accent ?? "colour75";
|
|
18
|
+
const border = theme.border ?? "colour238";
|
|
19
|
+
const bg = theme.bg ?? "colour235";
|
|
20
|
+
const fg = theme.fg ?? "colour248";
|
|
21
|
+
|
|
22
|
+
return [
|
|
23
|
+
["set-option", "-t", session, "status-style", `bg=${bg},fg=${fg}`],
|
|
24
|
+
["set-option", "-t", session, "pane-border-style", `fg=${border}`],
|
|
25
|
+
["set-option", "-t", session, "pane-active-border-style", `fg=${accent}`],
|
|
26
|
+
];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function borderOptions(session, theme) {
|
|
30
|
+
const accent = theme.accent ?? "colour75";
|
|
31
|
+
const border = theme.border ?? "colour238";
|
|
32
|
+
const fg = theme.fg ?? "colour248";
|
|
33
|
+
|
|
34
|
+
return [
|
|
35
|
+
["set-option", "-t", session, "pane-border-status", "top"],
|
|
36
|
+
[
|
|
37
|
+
"set-option",
|
|
38
|
+
"-t",
|
|
39
|
+
session,
|
|
40
|
+
"pane-border-format",
|
|
41
|
+
` #{?pane_active,#[fg=${accent}#,bold]▸ #T #[fg=${fg}]#{pane_current_path},#[fg=${border}]· #T #{pane_current_path}} `,
|
|
42
|
+
],
|
|
43
|
+
];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function behaviorOptions(session) {
|
|
47
|
+
return [
|
|
48
|
+
["set-option", "-t", session, "mouse", "on"],
|
|
49
|
+
["set-option", "-t", session, "escape-time", "0"],
|
|
50
|
+
["set-option", "-t", session, "status-interval", "1"],
|
|
51
|
+
];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function statusBarOptions(session, theme) {
|
|
55
|
+
const accent = theme.accent ?? "colour75";
|
|
56
|
+
const border = theme.border ?? "colour238";
|
|
57
|
+
const fg = theme.fg ?? "colour248";
|
|
58
|
+
|
|
59
|
+
// Pane tab components — each is a self-contained piece
|
|
60
|
+
const agentIndicator = [
|
|
61
|
+
`#{?#{==:#{@agent_busy},1},#[fg=${accent}]⏺ ,`,
|
|
62
|
+
`#{?#{==:#{@agent_idle},1},#[fg=${border}]● ,}}`,
|
|
63
|
+
].join("");
|
|
64
|
+
const portIndicator = `#{?#{==:#{@has_port},1},#[fg=green]⏺ ,}`;
|
|
65
|
+
const paneStyle = `#{?pane_active,#[fg=${accent}],#[fg=${border}]}`;
|
|
66
|
+
const paneTab = `${agentIndicator}${portIndicator}${paneStyle}#[range=pane|#{pane_id}] #T #[norange]#[default]`;
|
|
67
|
+
const separator = `#{?loop_last_flag,,#[fg=${border}]│}`;
|
|
68
|
+
|
|
69
|
+
return [
|
|
70
|
+
[
|
|
71
|
+
"set-option",
|
|
72
|
+
"-t",
|
|
73
|
+
session,
|
|
74
|
+
"status-left",
|
|
75
|
+
`#[fg=colour0,bg=${accent},bold] ${session.toUpperCase()} IDE #[default] `,
|
|
76
|
+
],
|
|
77
|
+
["set-option", "-t", session, "status-left-length", "30"],
|
|
78
|
+
[
|
|
79
|
+
"set-option",
|
|
80
|
+
"-t",
|
|
81
|
+
session,
|
|
82
|
+
"status-right",
|
|
83
|
+
`#[fg=colour243]%H:%M #[fg=${accent}]│ #[fg=${fg}]%b %d `,
|
|
84
|
+
],
|
|
85
|
+
["set-option", "-t", session, "status-justify", "centre"],
|
|
86
|
+
["set-option", "-t", session, "window-status-current-format", `#[fg=${accent},bold]●`],
|
|
87
|
+
["set-option", "-t", session, "window-status-format", `#[fg=${border}]○`],
|
|
88
|
+
["set-option", "-t", session, "status", "2"],
|
|
89
|
+
["set-option", "-t", session, "status-format[1]", ` #{P:${paneTab}${separator}}`],
|
|
90
|
+
];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function keyBindings() {
|
|
94
|
+
return [["bind-key", "-n", "MouseDown1StatusDefault", "select-pane", "-t", "="]];
|
|
95
|
+
}
|
package/src/lib/tmux.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import { execFileSync } from "node:child_process";
|
|
1
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
2
|
+
import { TmuxError } from "./errors.js";
|
|
3
|
+
|
|
4
|
+
const DEBUG = process.env.TMUX_IDE_DEBUG === "1";
|
|
2
5
|
|
|
3
6
|
const SESSION_NOT_FOUND_PATTERNS = ["can't find session", "can't find window", "unknown target"];
|
|
4
7
|
|
|
@@ -9,14 +12,7 @@ const TMUX_UNAVAILABLE_PATTERNS = [
|
|
|
9
12
|
"connection refused",
|
|
10
13
|
];
|
|
11
14
|
|
|
12
|
-
export
|
|
13
|
-
constructor(message, code, cause) {
|
|
14
|
-
super(message);
|
|
15
|
-
this.name = "TmuxError";
|
|
16
|
-
this.code = code;
|
|
17
|
-
this.cause = cause;
|
|
18
|
-
}
|
|
19
|
-
}
|
|
15
|
+
export { TmuxError };
|
|
20
16
|
|
|
21
17
|
export function getSessionState(session) {
|
|
22
18
|
try {
|
|
@@ -44,7 +40,10 @@ export function hasSession(session) {
|
|
|
44
40
|
runTmux(["has-session", "-t", session]);
|
|
45
41
|
return true;
|
|
46
42
|
} catch (error) {
|
|
47
|
-
if (
|
|
43
|
+
if (
|
|
44
|
+
error instanceof TmuxError &&
|
|
45
|
+
(error.code === "SESSION_NOT_FOUND" || error.code === "TMUX_UNAVAILABLE")
|
|
46
|
+
) {
|
|
48
47
|
return false;
|
|
49
48
|
}
|
|
50
49
|
throw error;
|
|
@@ -161,7 +160,47 @@ export function runSessionCommand(args) {
|
|
|
161
160
|
runTmux(args, { stdio: "inherit" });
|
|
162
161
|
}
|
|
163
162
|
|
|
163
|
+
export function startSessionMonitor(session, monitorScript) {
|
|
164
|
+
const child = spawn("node", [monitorScript, session], {
|
|
165
|
+
detached: true,
|
|
166
|
+
stdio: "ignore",
|
|
167
|
+
});
|
|
168
|
+
child.unref();
|
|
169
|
+
// Store PID as tmux session variable for later cleanup
|
|
170
|
+
runTmux(["set-option", "-t", session, "@monitor_pid", String(child.pid)]);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function stopSessionMonitor(session) {
|
|
174
|
+
try {
|
|
175
|
+
const pid = runTmux(["show-option", "-gqvt", session, "@monitor_pid"], {
|
|
176
|
+
encoding: "utf-8",
|
|
177
|
+
}).trim();
|
|
178
|
+
if (pid) process.kill(parseInt(pid, 10));
|
|
179
|
+
} catch {
|
|
180
|
+
/* session or process already gone */
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function getSessionVariable(session, name) {
|
|
185
|
+
try {
|
|
186
|
+
const raw = runTmux(["show-option", "-gqvt", session, name], {
|
|
187
|
+
encoding: "utf-8",
|
|
188
|
+
});
|
|
189
|
+
return raw.trim() || null;
|
|
190
|
+
} catch {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function setSessionVariable(session, name, value) {
|
|
196
|
+
runTmux(["set-option", "-t", session, name, value]);
|
|
197
|
+
}
|
|
198
|
+
|
|
164
199
|
function runTmux(args, options = {}) {
|
|
200
|
+
if (DEBUG || globalThis.__tmuxIdeVerbose) {
|
|
201
|
+
console.error(` [tmux] ${args.join(" ")}`);
|
|
202
|
+
}
|
|
203
|
+
|
|
165
204
|
const execOptions = {
|
|
166
205
|
stdio: ["ignore", "pipe", "pipe"],
|
|
167
206
|
...options,
|
|
@@ -178,18 +217,16 @@ function classifyTmuxError(error) {
|
|
|
178
217
|
const detail = getErrorDetail(error).toLowerCase();
|
|
179
218
|
|
|
180
219
|
if (SESSION_NOT_FOUND_PATTERNS.some((pattern) => detail.includes(pattern))) {
|
|
181
|
-
return new TmuxError("tmux session was not found", "SESSION_NOT_FOUND", error);
|
|
220
|
+
return new TmuxError("tmux session was not found", "SESSION_NOT_FOUND", { cause: error });
|
|
182
221
|
}
|
|
183
222
|
|
|
184
223
|
if (TMUX_UNAVAILABLE_PATTERNS.some((pattern) => detail.includes(pattern))) {
|
|
185
|
-
return new TmuxError(
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
error,
|
|
189
|
-
);
|
|
224
|
+
return new TmuxError("tmux is unavailable or its socket is inaccessible", "TMUX_UNAVAILABLE", {
|
|
225
|
+
cause: error,
|
|
226
|
+
});
|
|
190
227
|
}
|
|
191
228
|
|
|
192
|
-
return new TmuxError("tmux command failed", "TMUX_ERROR", error);
|
|
229
|
+
return new TmuxError("tmux command failed", "TMUX_ERROR", { cause: error });
|
|
193
230
|
}
|
|
194
231
|
|
|
195
232
|
function getErrorDetail(error) {
|
package/src/lib/yaml-io.js
CHANGED
|
@@ -19,8 +19,8 @@ export function writeConfig(dir, config) {
|
|
|
19
19
|
export function getSessionName(dir) {
|
|
20
20
|
try {
|
|
21
21
|
const { config } = readConfig(dir);
|
|
22
|
-
return config.name ?? basename(dir);
|
|
22
|
+
return { name: config.name ?? basename(dir), source: config.name ? "config" : "fallback" };
|
|
23
23
|
} catch {
|
|
24
|
-
return basename(dir);
|
|
24
|
+
return { name: basename(dir), source: "fallback" };
|
|
25
25
|
}
|
|
26
26
|
}
|
package/src/restart.js
CHANGED
|
@@ -5,7 +5,7 @@ import { killSession } from "./lib/tmux.js";
|
|
|
5
5
|
|
|
6
6
|
export async function restart(targetDir, { json, attach } = {}) {
|
|
7
7
|
const dir = resolve(targetDir ?? ".");
|
|
8
|
-
const session = getSessionName(dir);
|
|
8
|
+
const { name: session } = getSessionName(dir);
|
|
9
9
|
const result = killSession(session);
|
|
10
10
|
|
|
11
11
|
if (result.stopped) {
|
package/src/status.js
CHANGED
|
@@ -5,7 +5,7 @@ import { getSessionState, listPanes } from "./lib/tmux.js";
|
|
|
5
5
|
|
|
6
6
|
export async function status(targetDir, { json } = {}) {
|
|
7
7
|
const dir = resolve(targetDir ?? ".");
|
|
8
|
-
const session = getSessionName(dir);
|
|
8
|
+
const { name: session } = getSessionName(dir);
|
|
9
9
|
const configExists = existsSync(resolve(dir, "ide.yml"));
|
|
10
10
|
|
|
11
11
|
const state = getSessionState(session);
|
package/src/stop.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import { getSessionName } from "./lib/yaml-io.js";
|
|
3
3
|
import { outputError } from "./lib/output.js";
|
|
4
|
-
import { killSession } from "./lib/tmux.js";
|
|
4
|
+
import { killSession, stopSessionMonitor } from "./lib/tmux.js";
|
|
5
5
|
|
|
6
6
|
export async function stop(targetDir, { json } = {}) {
|
|
7
7
|
const dir = resolve(targetDir ?? ".");
|
|
8
|
-
const session = getSessionName(dir);
|
|
8
|
+
const { name: session } = getSessionName(dir);
|
|
9
|
+
|
|
10
|
+
// Stop the session monitor before killing the session
|
|
11
|
+
stopSessionMonitor(session);
|
|
12
|
+
|
|
9
13
|
const result = killSession(session);
|
|
10
14
|
|
|
11
15
|
if (result.stopped) {
|
|
@@ -17,8 +21,5 @@ export async function stop(targetDir, { json } = {}) {
|
|
|
17
21
|
return;
|
|
18
22
|
}
|
|
19
23
|
|
|
20
|
-
outputError(`No active session "${session}" found`, "NOT_RUNNING"
|
|
21
|
-
json,
|
|
22
|
-
exitCode: 1,
|
|
23
|
-
});
|
|
24
|
+
outputError(`No active session "${session}" found`, "NOT_RUNNING");
|
|
24
25
|
}
|
package/src/validate.js
CHANGED
|
@@ -130,7 +130,7 @@ export async function validate(targetDir, { json } = {}) {
|
|
|
130
130
|
try {
|
|
131
131
|
({ config } = readConfig(dir));
|
|
132
132
|
} catch (e) {
|
|
133
|
-
outputError(`Cannot read ide.yml: ${e.message}`, "READ_ERROR"
|
|
133
|
+
outputError(`Cannot read ide.yml: ${e.message}`, "READ_ERROR");
|
|
134
134
|
return;
|
|
135
135
|
}
|
|
136
136
|
|