triflux 10.22.0 → 10.23.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/CLAUDE.md +3 -2
- package/bin/triflux.mjs +449 -16
- package/hooks/hook-orchestrator.mjs +19 -2
- package/hooks/pipeline-stop.mjs +11 -0
- package/hooks/session-end-cleanup.mjs +156 -12
- package/hub/team/session.mjs +2 -2
- package/hub/team/terminal-opener.mjs +27 -9
- package/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -97,10 +97,11 @@ conductor, headless, swarm-hypervisor가 하나의 AccountBroker 싱글턴을
|
|
|
97
97
|
| 스킬 | 대상 | 방식 |
|
|
98
98
|
|------|------|------|
|
|
99
99
|
| tfx-codex-swarm | 로컬 전용 | 로컬 worktree + psmux |
|
|
100
|
-
| tfx-remote
|
|
100
|
+
| tfx-remote | Claude Code 원격 | SSH → Claude Code 세션 → 내부 tfx 라우팅 |
|
|
101
|
+
| tfx-remote-spawn | deprecated alias | tfx-remote로 통합됨; 직접 호출 금지 |
|
|
101
102
|
|
|
102
103
|
codex를 SSH 너머로 직접 실행하지 않는다. config.toml 충돌 + TTY 문제.
|
|
103
|
-
원격에서 codex가 필요하면: remote
|
|
104
|
+
원격에서 codex가 필요하면: tfx-remote → Claude Code → Claude가 내부에서 codex 호출.
|
|
104
105
|
|
|
105
106
|
### SSH 패턴
|
|
106
107
|
|
package/bin/triflux.mjs
CHANGED
|
@@ -120,6 +120,8 @@ const VER = `${DIM}v${PKG.version}${RESET}`;
|
|
|
120
120
|
const LINE = `${GRAY}${"─".repeat(48)}${RESET}`;
|
|
121
121
|
const _DOT = `${GRAY}·${RESET}`;
|
|
122
122
|
const STALE_TEAM_MAX_AGE_SEC = 3600;
|
|
123
|
+
const DEFAULT_TMUX_CLEANUP_PREFIX = "tfx-*";
|
|
124
|
+
const DEFAULT_TMUX_CLEANUP_AGE_MIN = 60;
|
|
123
125
|
const ANSI_PATTERN = /\x1B\[[0-?]*[ -/]*[@-~]/g;
|
|
124
126
|
const HUB_DEFAULT_PORT = 27888;
|
|
125
127
|
const DOCTOR_HUB_PID_FILE = join(CLAUDE_DIR, "cache", "tfx-hub", "hub.pid");
|
|
@@ -155,7 +157,7 @@ const CLI_COMMAND_SCHEMAS = Object.freeze({
|
|
|
155
157
|
},
|
|
156
158
|
doctor: {
|
|
157
159
|
usage:
|
|
158
|
-
"tfx doctor [--fix] [--reset] [--audit] [--diagnose] [--purge-logs] [--dynamic-routing] [--cleanup-stale-hubs --dry-run|--apply] [--json]",
|
|
160
|
+
"tfx doctor [--fix] [--reset] [--audit] [--diagnose] [--purge-logs] [--dynamic-routing] [--cleanup-stale-hubs --dry-run|--apply] [--cleanup-stale-tmux --prefix tfx-* --age-min N --dry-run|--apply] [--json]",
|
|
159
161
|
description: "설치 상태 진단 및 자동 복구",
|
|
160
162
|
options: [
|
|
161
163
|
{
|
|
@@ -197,17 +199,33 @@ const CLI_COMMAND_SCHEMAS = Object.freeze({
|
|
|
197
199
|
description:
|
|
198
200
|
"PPID=1 hub/server.mjs 후보를 보고하고 opt-in 정리 모드를 활성화",
|
|
199
201
|
},
|
|
202
|
+
{
|
|
203
|
+
name: "--cleanup-stale-tmux",
|
|
204
|
+
type: "boolean",
|
|
205
|
+
description:
|
|
206
|
+
"detached tmux session 후보를 보고하고 opt-in 정리 모드를 활성화",
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
name: "--prefix <glob>",
|
|
210
|
+
type: "string",
|
|
211
|
+
description: "--cleanup-stale-tmux 와 함께 사용. 기본 tfx-*",
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
name: "--age-min <minutes>",
|
|
215
|
+
type: "number",
|
|
216
|
+
description: "--cleanup-stale-tmux 와 함께 사용. 기본 60",
|
|
217
|
+
},
|
|
200
218
|
{
|
|
201
219
|
name: "--dry-run",
|
|
202
220
|
type: "boolean",
|
|
203
221
|
description:
|
|
204
|
-
"--cleanup-stale-hubs 와 함께 사용.
|
|
222
|
+
"--cleanup-stale-hubs/--cleanup-stale-tmux 와 함께 사용. 정리 후보만 표시",
|
|
205
223
|
},
|
|
206
224
|
{
|
|
207
225
|
name: "--apply",
|
|
208
226
|
type: "boolean",
|
|
209
227
|
description:
|
|
210
|
-
"--cleanup-stale-hubs 와 함께 사용.
|
|
228
|
+
"--cleanup-stale-hubs/--cleanup-stale-tmux 와 함께 사용. stale 대상 종료",
|
|
211
229
|
},
|
|
212
230
|
{
|
|
213
231
|
name: "--json",
|
|
@@ -823,14 +841,23 @@ export async function inspectDetachedHubProcesses({
|
|
|
823
841
|
healthy = true;
|
|
824
842
|
break;
|
|
825
843
|
}
|
|
844
|
+
const activeByPidFile = activeHealthy?.pid === row.pid;
|
|
845
|
+
const activeByConnection = established >= 1;
|
|
846
|
+
const isActiveHealthy = activeByPidFile || activeByConnection;
|
|
826
847
|
hubs.push({
|
|
827
848
|
...row,
|
|
828
849
|
ports,
|
|
829
850
|
established,
|
|
830
851
|
version,
|
|
831
|
-
healthy,
|
|
832
|
-
activeHealthy:
|
|
833
|
-
|
|
852
|
+
healthy: healthy || activeByConnection,
|
|
853
|
+
activeHealthy: isActiveHealthy,
|
|
854
|
+
activeReason: activeByPidFile
|
|
855
|
+
? "pid-file-health"
|
|
856
|
+
: activeByConnection
|
|
857
|
+
? "established-connection"
|
|
858
|
+
: null,
|
|
859
|
+
healthStatus: isActiveHealthy ? "healthy" : "stale",
|
|
860
|
+
staleCandidate: !isActiveHealthy,
|
|
834
861
|
});
|
|
835
862
|
}
|
|
836
863
|
|
|
@@ -891,12 +918,30 @@ export async function cleanupDetachedHubProcesses({
|
|
|
891
918
|
} = {}) {
|
|
892
919
|
const results = [];
|
|
893
920
|
for (const hub of hubs || []) {
|
|
894
|
-
|
|
895
|
-
|
|
921
|
+
const classification =
|
|
922
|
+
activeHealthy?.pid === hub.pid ||
|
|
923
|
+
hub.activeHealthy ||
|
|
924
|
+
Number(hub.established) >= 1
|
|
925
|
+
? "healthy"
|
|
926
|
+
: "stale";
|
|
927
|
+
if (classification === "healthy") {
|
|
928
|
+
results.push({
|
|
929
|
+
pid: hub.pid,
|
|
930
|
+
classification,
|
|
931
|
+
action: "excluded-active",
|
|
932
|
+
ok: true,
|
|
933
|
+
hub,
|
|
934
|
+
});
|
|
896
935
|
continue;
|
|
897
936
|
}
|
|
898
937
|
if (!apply || dryRun) {
|
|
899
|
-
results.push({
|
|
938
|
+
results.push({
|
|
939
|
+
pid: hub.pid,
|
|
940
|
+
classification,
|
|
941
|
+
action: "dry-run-skip",
|
|
942
|
+
ok: true,
|
|
943
|
+
hub,
|
|
944
|
+
});
|
|
900
945
|
continue;
|
|
901
946
|
}
|
|
902
947
|
const retired = await retireDetachedHubPid(hub.pid, {
|
|
@@ -906,6 +951,7 @@ export async function cleanupDetachedHubProcesses({
|
|
|
906
951
|
});
|
|
907
952
|
results.push({
|
|
908
953
|
pid: hub.pid,
|
|
954
|
+
classification,
|
|
909
955
|
action: retired.ok ? "retired" : "failed",
|
|
910
956
|
ok: retired.ok,
|
|
911
957
|
reason: retired.reason,
|
|
@@ -1110,6 +1156,240 @@ function formatElapsedAge(ageSec) {
|
|
|
1110
1156
|
return `${Math.floor(ageSec / 86400)}일`;
|
|
1111
1157
|
}
|
|
1112
1158
|
|
|
1159
|
+
function compileSimpleGlob(pattern) {
|
|
1160
|
+
const source = String(pattern || DEFAULT_TMUX_CLEANUP_PREFIX)
|
|
1161
|
+
.replace(/[.+?^${}()|[\]\\]/g, "\\$&")
|
|
1162
|
+
.replace(/\*/g, ".*");
|
|
1163
|
+
return new RegExp(`^${source}$`);
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
function parseDetachedTmuxSessionRows(output, nowSec) {
|
|
1167
|
+
const sessions = [];
|
|
1168
|
+
for (const line of String(output || "").split(/\r?\n/)) {
|
|
1169
|
+
const [name, attachedText, createdText] = line.split("\t");
|
|
1170
|
+
if (!name) continue;
|
|
1171
|
+
const attached = Number.parseInt(attachedText || "0", 10);
|
|
1172
|
+
const createdAt = parseSessionCreated(createdText);
|
|
1173
|
+
sessions.push({
|
|
1174
|
+
name,
|
|
1175
|
+
attached: Number.isFinite(attached) ? attached : 0,
|
|
1176
|
+
ageSec: createdAt == null ? null : Math.max(0, nowSec - createdAt),
|
|
1177
|
+
});
|
|
1178
|
+
}
|
|
1179
|
+
return sessions;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
function parseTmuxPaneRows(output) {
|
|
1183
|
+
return String(output || "")
|
|
1184
|
+
.split(/\r?\n/)
|
|
1185
|
+
.filter((line) => line.trim())
|
|
1186
|
+
.map((line) => {
|
|
1187
|
+
const [cwd, command, pidText] = line.split("\t");
|
|
1188
|
+
const pid = Number.parseInt(pidText || "", 10);
|
|
1189
|
+
return {
|
|
1190
|
+
cwd: cwd || null,
|
|
1191
|
+
command: command || null,
|
|
1192
|
+
pid: Number.isFinite(pid) && pid > 0 ? pid : null,
|
|
1193
|
+
};
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
function queryTmuxPaneRows(sessionName, { execFile = execFileSync } = {}) {
|
|
1198
|
+
try {
|
|
1199
|
+
return parseTmuxPaneRows(
|
|
1200
|
+
execFile(
|
|
1201
|
+
"tmux",
|
|
1202
|
+
[
|
|
1203
|
+
"list-panes",
|
|
1204
|
+
"-t",
|
|
1205
|
+
sessionName,
|
|
1206
|
+
"-F",
|
|
1207
|
+
"#{pane_current_path}\t#{pane_current_command}\t#{pane_pid}",
|
|
1208
|
+
],
|
|
1209
|
+
{
|
|
1210
|
+
encoding: "utf8",
|
|
1211
|
+
timeout: 1000,
|
|
1212
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
1213
|
+
windowsHide: true,
|
|
1214
|
+
},
|
|
1215
|
+
),
|
|
1216
|
+
);
|
|
1217
|
+
} catch {
|
|
1218
|
+
return [];
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
function queryProcessMemoryMb(pid, { execFile = execFileSync } = {}) {
|
|
1223
|
+
const resolvedPid = Number(pid);
|
|
1224
|
+
if (!Number.isFinite(resolvedPid) || resolvedPid <= 0) return null;
|
|
1225
|
+
try {
|
|
1226
|
+
const output = execFile("ps", ["-o", "rss=", "-p", String(resolvedPid)], {
|
|
1227
|
+
encoding: "utf8",
|
|
1228
|
+
timeout: 1000,
|
|
1229
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
1230
|
+
windowsHide: true,
|
|
1231
|
+
});
|
|
1232
|
+
const rssKb = Number.parseInt(String(output || "").trim(), 10);
|
|
1233
|
+
if (!Number.isFinite(rssKb) || rssKb < 0) return null;
|
|
1234
|
+
return Math.round(rssKb / 1024);
|
|
1235
|
+
} catch {
|
|
1236
|
+
return null;
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
function sumMemoryEstimateMb(panes, { execFile = execFileSync } = {}) {
|
|
1241
|
+
let total = 0;
|
|
1242
|
+
let seen = false;
|
|
1243
|
+
for (const pane of panes) {
|
|
1244
|
+
const memory = queryProcessMemoryMb(pane.pid, { execFile });
|
|
1245
|
+
if (memory == null) continue;
|
|
1246
|
+
total += memory;
|
|
1247
|
+
seen = true;
|
|
1248
|
+
}
|
|
1249
|
+
return seen ? total : null;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
export function inspectDetachedTmuxSessions({
|
|
1253
|
+
prefix = DEFAULT_TMUX_CLEANUP_PREFIX,
|
|
1254
|
+
ageMin = DEFAULT_TMUX_CLEANUP_AGE_MIN,
|
|
1255
|
+
platform = process.platform,
|
|
1256
|
+
execFile = execFileSync,
|
|
1257
|
+
now = Date.now(),
|
|
1258
|
+
} = {}) {
|
|
1259
|
+
if (platform === "win32") {
|
|
1260
|
+
return {
|
|
1261
|
+
available: false,
|
|
1262
|
+
reason: "unsupported-platform",
|
|
1263
|
+
prefix,
|
|
1264
|
+
ageMin,
|
|
1265
|
+
sessions: [],
|
|
1266
|
+
staleCandidates: [],
|
|
1267
|
+
};
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
let output = "";
|
|
1271
|
+
try {
|
|
1272
|
+
output = execFile(
|
|
1273
|
+
"tmux",
|
|
1274
|
+
[
|
|
1275
|
+
"list-sessions",
|
|
1276
|
+
"-F",
|
|
1277
|
+
"#{session_name}\t#{session_attached}\t#{session_created}",
|
|
1278
|
+
],
|
|
1279
|
+
{
|
|
1280
|
+
encoding: "utf8",
|
|
1281
|
+
timeout: 1000,
|
|
1282
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
1283
|
+
windowsHide: true,
|
|
1284
|
+
},
|
|
1285
|
+
);
|
|
1286
|
+
} catch (error) {
|
|
1287
|
+
return {
|
|
1288
|
+
available: false,
|
|
1289
|
+
reason: error?.code === "ENOENT" ? "tmux-missing" : "tmux-unavailable",
|
|
1290
|
+
prefix,
|
|
1291
|
+
ageMin,
|
|
1292
|
+
sessions: [],
|
|
1293
|
+
staleCandidates: [],
|
|
1294
|
+
};
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
const nowSec = Math.floor(now / 1000);
|
|
1298
|
+
const matcher = compileSimpleGlob(prefix);
|
|
1299
|
+
const minAgeSec = Math.max(0, Number(ageMin) || 0) * 60;
|
|
1300
|
+
const sessions = parseDetachedTmuxSessionRows(output, nowSec)
|
|
1301
|
+
.filter((session) => session.attached === 0 && matcher.test(session.name))
|
|
1302
|
+
.map((session) => {
|
|
1303
|
+
const panes = queryTmuxPaneRows(session.name, { execFile });
|
|
1304
|
+
const commands = [
|
|
1305
|
+
...new Set(panes.map((pane) => pane.command).filter(Boolean)),
|
|
1306
|
+
];
|
|
1307
|
+
const cwd = panes.find((pane) => pane.cwd)?.cwd || null;
|
|
1308
|
+
const staleCandidate =
|
|
1309
|
+
session.ageSec != null && session.ageSec >= minAgeSec;
|
|
1310
|
+
return {
|
|
1311
|
+
name: session.name,
|
|
1312
|
+
age: formatElapsedAge(session.ageSec),
|
|
1313
|
+
ageSec: session.ageSec,
|
|
1314
|
+
cwd,
|
|
1315
|
+
command: commands.length > 0 ? commands.join(",") : null,
|
|
1316
|
+
memoryEstimateMb: sumMemoryEstimateMb(panes, { execFile }),
|
|
1317
|
+
staleCandidate,
|
|
1318
|
+
};
|
|
1319
|
+
});
|
|
1320
|
+
|
|
1321
|
+
return {
|
|
1322
|
+
available: true,
|
|
1323
|
+
reason: null,
|
|
1324
|
+
prefix,
|
|
1325
|
+
ageMin,
|
|
1326
|
+
sessions,
|
|
1327
|
+
staleCandidates: sessions.filter((session) => session.staleCandidate),
|
|
1328
|
+
};
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
export async function cleanupDetachedTmuxSessions({
|
|
1332
|
+
sessions,
|
|
1333
|
+
dryRun = true,
|
|
1334
|
+
apply = false,
|
|
1335
|
+
execFile = execFileSync,
|
|
1336
|
+
} = {}) {
|
|
1337
|
+
const results = [];
|
|
1338
|
+
for (const session of sessions || []) {
|
|
1339
|
+
if (!session.staleCandidate) {
|
|
1340
|
+
results.push({
|
|
1341
|
+
name: session.name,
|
|
1342
|
+
action: "excluded-fresh",
|
|
1343
|
+
ok: true,
|
|
1344
|
+
session,
|
|
1345
|
+
});
|
|
1346
|
+
continue;
|
|
1347
|
+
}
|
|
1348
|
+
if (!apply || dryRun) {
|
|
1349
|
+
results.push({
|
|
1350
|
+
name: session.name,
|
|
1351
|
+
action: "dry-run-skip",
|
|
1352
|
+
ok: true,
|
|
1353
|
+
session,
|
|
1354
|
+
});
|
|
1355
|
+
continue;
|
|
1356
|
+
}
|
|
1357
|
+
try {
|
|
1358
|
+
execFile("tmux", ["kill-session", "-t", session.name], {
|
|
1359
|
+
encoding: "utf8",
|
|
1360
|
+
timeout: 5000,
|
|
1361
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1362
|
+
windowsHide: true,
|
|
1363
|
+
});
|
|
1364
|
+
results.push({
|
|
1365
|
+
name: session.name,
|
|
1366
|
+
action: "retired",
|
|
1367
|
+
ok: true,
|
|
1368
|
+
session,
|
|
1369
|
+
});
|
|
1370
|
+
} catch (error) {
|
|
1371
|
+
results.push({
|
|
1372
|
+
name: session.name,
|
|
1373
|
+
action: "failed",
|
|
1374
|
+
ok: false,
|
|
1375
|
+
error,
|
|
1376
|
+
session,
|
|
1377
|
+
});
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
return {
|
|
1382
|
+
dryRun: !apply || dryRun,
|
|
1383
|
+
results,
|
|
1384
|
+
failed: results.filter((result) => result.ok === false).length,
|
|
1385
|
+
retired: results.filter((result) => result.action === "retired").length,
|
|
1386
|
+
skipped: results.filter((result) => result.action === "dry-run-skip")
|
|
1387
|
+
.length,
|
|
1388
|
+
excluded: results.filter((result) => result.action === "excluded-fresh")
|
|
1389
|
+
.length,
|
|
1390
|
+
};
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1113
1393
|
function readTeamSessionCreatedMap() {
|
|
1114
1394
|
const createdMap = new Map();
|
|
1115
1395
|
|
|
@@ -2286,6 +2566,11 @@ async function cmdDoctor(options = {}) {
|
|
|
2286
2566
|
cleanupStaleHubs = false,
|
|
2287
2567
|
cleanupStaleHubsDryRun = true,
|
|
2288
2568
|
cleanupStaleHubsApply = false,
|
|
2569
|
+
cleanupStaleTmux = false,
|
|
2570
|
+
cleanupStaleTmuxDryRun = true,
|
|
2571
|
+
cleanupStaleTmuxApply = false,
|
|
2572
|
+
cleanupStaleTmuxPrefix = DEFAULT_TMUX_CLEANUP_PREFIX,
|
|
2573
|
+
cleanupStaleTmuxAgeMin = DEFAULT_TMUX_CLEANUP_AGE_MIN,
|
|
2289
2574
|
json = false,
|
|
2290
2575
|
} = options;
|
|
2291
2576
|
const report = {
|
|
@@ -2296,6 +2581,13 @@ async function cmdDoctor(options = {}) {
|
|
|
2296
2581
|
hook_coverage: { total: 0, registered: 0, missing: [] },
|
|
2297
2582
|
fsmonitorDaemons: { stale: 0, killed: 0 },
|
|
2298
2583
|
hubServers: { detached: 0, stale: 0, activeHealthy: null },
|
|
2584
|
+
tmuxSessions: {
|
|
2585
|
+
detached: 0,
|
|
2586
|
+
stale: 0,
|
|
2587
|
+
prefix: cleanupStaleTmuxPrefix,
|
|
2588
|
+
ageMin: cleanupStaleTmuxAgeMin,
|
|
2589
|
+
sessions: [],
|
|
2590
|
+
},
|
|
2299
2591
|
issue_count: 0,
|
|
2300
2592
|
};
|
|
2301
2593
|
|
|
@@ -3583,6 +3875,106 @@ async function cmdDoctor(options = {}) {
|
|
|
3583
3875
|
}
|
|
3584
3876
|
}
|
|
3585
3877
|
|
|
3878
|
+
// 12.5. detached tmux session report and opt-in cleanup
|
|
3879
|
+
section("Detached Tmux Sessions");
|
|
3880
|
+
const detachedTmuxReport = inspectDetachedTmuxSessions({
|
|
3881
|
+
prefix: cleanupStaleTmuxPrefix,
|
|
3882
|
+
ageMin: cleanupStaleTmuxAgeMin,
|
|
3883
|
+
});
|
|
3884
|
+
report.tmuxSessions = {
|
|
3885
|
+
detached: detachedTmuxReport.sessions.length,
|
|
3886
|
+
stale: detachedTmuxReport.staleCandidates.length,
|
|
3887
|
+
prefix: detachedTmuxReport.prefix,
|
|
3888
|
+
ageMin: detachedTmuxReport.ageMin,
|
|
3889
|
+
available: detachedTmuxReport.available,
|
|
3890
|
+
reason: detachedTmuxReport.reason,
|
|
3891
|
+
sessions: detachedTmuxReport.sessions.map((session) => ({
|
|
3892
|
+
name: session.name,
|
|
3893
|
+
age: session.age,
|
|
3894
|
+
age_sec: session.ageSec,
|
|
3895
|
+
cwd: session.cwd,
|
|
3896
|
+
command: session.command,
|
|
3897
|
+
memory_estimate_mb: session.memoryEstimateMb,
|
|
3898
|
+
stale: session.staleCandidate,
|
|
3899
|
+
})),
|
|
3900
|
+
};
|
|
3901
|
+
addDoctorCheck(report, {
|
|
3902
|
+
name: "detached-tmux-sessions",
|
|
3903
|
+
status: !detachedTmuxReport.available
|
|
3904
|
+
? "skipped"
|
|
3905
|
+
: detachedTmuxReport.staleCandidates.length > 0
|
|
3906
|
+
? "warning"
|
|
3907
|
+
: "ok",
|
|
3908
|
+
prefix: detachedTmuxReport.prefix,
|
|
3909
|
+
age_min: detachedTmuxReport.ageMin,
|
|
3910
|
+
detached: detachedTmuxReport.sessions.length,
|
|
3911
|
+
stale: detachedTmuxReport.staleCandidates.length,
|
|
3912
|
+
reason: detachedTmuxReport.reason,
|
|
3913
|
+
});
|
|
3914
|
+
|
|
3915
|
+
if (!detachedTmuxReport.available) {
|
|
3916
|
+
info(
|
|
3917
|
+
`tmux 미감지 또는 서버 없음 — detached tmux 검사 건너뜀 (${detachedTmuxReport.reason})`,
|
|
3918
|
+
);
|
|
3919
|
+
} else if (detachedTmuxReport.sessions.length === 0) {
|
|
3920
|
+
ok(`detached tmux session 없음 (prefix=${detachedTmuxReport.prefix})`);
|
|
3921
|
+
} else {
|
|
3922
|
+
info(
|
|
3923
|
+
`prefix=${detachedTmuxReport.prefix} age-min=${detachedTmuxReport.ageMin}분`,
|
|
3924
|
+
);
|
|
3925
|
+
for (const session of detachedTmuxReport.sessions) {
|
|
3926
|
+
const memoryLabel =
|
|
3927
|
+
session.memoryEstimateMb == null
|
|
3928
|
+
? "?"
|
|
3929
|
+
: `${session.memoryEstimateMb}MB`;
|
|
3930
|
+
const cwdLabel = session.cwd || "?";
|
|
3931
|
+
const commandLabel = session.command || "?";
|
|
3932
|
+
const line = `${session.name}: age=${session.age} cwd=${cwdLabel} command=${commandLabel} memory=${memoryLabel}`;
|
|
3933
|
+
if (session.staleCandidate) warn(`${line} stale`);
|
|
3934
|
+
else ok(`${line} fresh`);
|
|
3935
|
+
}
|
|
3936
|
+
}
|
|
3937
|
+
|
|
3938
|
+
if (cleanupStaleTmux) {
|
|
3939
|
+
const cleanupResult = await cleanupDetachedTmuxSessions({
|
|
3940
|
+
sessions: detachedTmuxReport.sessions,
|
|
3941
|
+
dryRun: cleanupStaleTmuxDryRun,
|
|
3942
|
+
apply: cleanupStaleTmuxApply,
|
|
3943
|
+
});
|
|
3944
|
+
report.actions.push({
|
|
3945
|
+
type: "cleanup-stale-tmux",
|
|
3946
|
+
status: cleanupResult.failed > 0 ? "failed" : "ok",
|
|
3947
|
+
dryRun: cleanupResult.dryRun,
|
|
3948
|
+
retired: cleanupResult.retired,
|
|
3949
|
+
skipped: cleanupResult.skipped,
|
|
3950
|
+
excluded: cleanupResult.excluded,
|
|
3951
|
+
failed: cleanupResult.failed,
|
|
3952
|
+
});
|
|
3953
|
+
for (const result of cleanupResult.results) {
|
|
3954
|
+
if (result.action === "excluded-fresh") {
|
|
3955
|
+
ok(`fresh detached tmux excluded: ${result.name}`);
|
|
3956
|
+
} else if (result.action === "dry-run-skip") {
|
|
3957
|
+
info(`dry-run stale tmux target: ${result.name}`);
|
|
3958
|
+
} else if (result.action === "retired") {
|
|
3959
|
+
ok(`stale tmux session killed: ${result.name}`);
|
|
3960
|
+
} else {
|
|
3961
|
+
fail(`stale tmux cleanup failed: ${result.name}`);
|
|
3962
|
+
}
|
|
3963
|
+
}
|
|
3964
|
+
issues += cleanupResult.failed;
|
|
3965
|
+
if (
|
|
3966
|
+
cleanupResult.dryRun &&
|
|
3967
|
+
detachedTmuxReport.staleCandidates.length > 0
|
|
3968
|
+
) {
|
|
3969
|
+
issues += detachedTmuxReport.staleCandidates.length;
|
|
3970
|
+
}
|
|
3971
|
+
} else if (detachedTmuxReport.staleCandidates.length > 0) {
|
|
3972
|
+
info(
|
|
3973
|
+
"정리: tfx doctor --cleanup-stale-tmux --prefix tfx-* --dry-run|--apply",
|
|
3974
|
+
);
|
|
3975
|
+
issues += detachedTmuxReport.staleCandidates.length;
|
|
3976
|
+
}
|
|
3977
|
+
|
|
3586
3978
|
// 13. OMC stale team 상태
|
|
3587
3979
|
section("OMC Stale Teams");
|
|
3588
3980
|
const omcTeamReport = inspectStaleOmcTeams({
|
|
@@ -3673,6 +4065,8 @@ async function cmdDoctor(options = {}) {
|
|
|
3673
4065
|
established: hub.established,
|
|
3674
4066
|
rssKb: hub.rssKb,
|
|
3675
4067
|
activeHealthy: hub.activeHealthy,
|
|
4068
|
+
activeReason: hub.activeReason,
|
|
4069
|
+
healthStatus: hub.healthStatus,
|
|
3676
4070
|
staleCandidate: hub.staleCandidate,
|
|
3677
4071
|
})),
|
|
3678
4072
|
};
|
|
@@ -3692,9 +4086,9 @@ async function cmdDoctor(options = {}) {
|
|
|
3692
4086
|
const versionLabel = hub.version || "unknown";
|
|
3693
4087
|
const rssLabel = hub.rssKb == null ? "?" : `${hub.rssKb}KB`;
|
|
3694
4088
|
const activeLabel = hub.activeHealthy
|
|
3695
|
-
?
|
|
4089
|
+
? ` healthy excluded${hub.activeReason ? ` (${hub.activeReason})` : ""}`
|
|
3696
4090
|
: " stale candidate";
|
|
3697
|
-
const line = `PID=${hub.pid} PPID=${hub.ppid} version=${versionLabel} uptime=${hub.uptime} port=${portLabel} ESTABLISHED=${hub.established} RSS=${rssLabel}${activeLabel}`;
|
|
4091
|
+
const line = `PID=${hub.pid} PPID=${hub.ppid} status=${hub.healthStatus} version=${versionLabel} uptime=${hub.uptime} port=${portLabel} ESTABLISHED=${hub.established} RSS=${rssLabel}${activeLabel}`;
|
|
3698
4092
|
if (hub.activeHealthy) ok(line);
|
|
3699
4093
|
else warn(line);
|
|
3700
4094
|
}
|
|
@@ -3718,9 +4112,9 @@ async function cmdDoctor(options = {}) {
|
|
|
3718
4112
|
});
|
|
3719
4113
|
for (const result of cleanupResult.results) {
|
|
3720
4114
|
if (result.action === "excluded-active") {
|
|
3721
|
-
ok(`
|
|
4115
|
+
ok(`dry-run healthy hub excluded: PID=${result.pid}`);
|
|
3722
4116
|
} else if (result.action === "dry-run-skip") {
|
|
3723
|
-
info(`dry-run
|
|
4117
|
+
info(`dry-run stale hub target: PID=${result.pid}`);
|
|
3724
4118
|
} else if (result.action === "retired") {
|
|
3725
4119
|
ok(`stale hub retired: PID=${result.pid} (${result.reason})`);
|
|
3726
4120
|
} else {
|
|
@@ -6430,6 +6824,28 @@ async function cmdHub(args = [], options = {}) {
|
|
|
6430
6824
|
|
|
6431
6825
|
// ── 메인 ──
|
|
6432
6826
|
|
|
6827
|
+
function readCliOptionValue(args, name) {
|
|
6828
|
+
const index = args.indexOf(name);
|
|
6829
|
+
if (index === -1) return null;
|
|
6830
|
+
const value = args[index + 1];
|
|
6831
|
+
if (!value || value.startsWith("--")) return null;
|
|
6832
|
+
return value;
|
|
6833
|
+
}
|
|
6834
|
+
|
|
6835
|
+
function parsePositiveIntegerOption(args, name, fallback) {
|
|
6836
|
+
const value = readCliOptionValue(args, name);
|
|
6837
|
+
if (value == null) return fallback;
|
|
6838
|
+
const parsed = Number.parseInt(value, 10);
|
|
6839
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
6840
|
+
throw createCliError(`${name} 값은 0 이상의 정수여야 합니다`, {
|
|
6841
|
+
exitCode: EXIT_ARG_ERROR,
|
|
6842
|
+
reason: "argError",
|
|
6843
|
+
fix: `${name} ${fallback}`,
|
|
6844
|
+
});
|
|
6845
|
+
}
|
|
6846
|
+
return parsed;
|
|
6847
|
+
}
|
|
6848
|
+
|
|
6433
6849
|
async function main() {
|
|
6434
6850
|
const cmd = NORMALIZED_ARGS[0] || "help";
|
|
6435
6851
|
const cmdArgs = NORMALIZED_ARGS.slice(1);
|
|
@@ -6526,18 +6942,30 @@ async function main() {
|
|
|
6526
6942
|
const reset = cmdArgs.includes("--reset");
|
|
6527
6943
|
const purgeLogs = cmdArgs.includes("--purge-logs");
|
|
6528
6944
|
const cleanupStaleHubs = cmdArgs.includes("--cleanup-stale-hubs");
|
|
6945
|
+
const cleanupStaleTmux = cmdArgs.includes("--cleanup-stale-tmux");
|
|
6529
6946
|
const cleanupApply = cmdArgs.includes("--apply");
|
|
6530
6947
|
const cleanupDryRun = cmdArgs.includes("--dry-run") || !cleanupApply;
|
|
6531
|
-
if (
|
|
6948
|
+
if (
|
|
6949
|
+
(cleanupStaleHubs || cleanupStaleTmux) &&
|
|
6950
|
+
cleanupApply &&
|
|
6951
|
+
cmdArgs.includes("--dry-run")
|
|
6952
|
+
) {
|
|
6532
6953
|
throw createCliError(
|
|
6533
|
-
"
|
|
6954
|
+
"cleanup 옵션에서는 --dry-run 과 --apply 중 하나만 지정하세요",
|
|
6534
6955
|
{
|
|
6535
6956
|
exitCode: EXIT_ARG_ERROR,
|
|
6536
6957
|
reason: "argError",
|
|
6537
|
-
fix: "tfx doctor --cleanup-stale-
|
|
6958
|
+
fix: "tfx doctor --cleanup-stale-tmux --dry-run",
|
|
6538
6959
|
},
|
|
6539
6960
|
);
|
|
6540
6961
|
}
|
|
6962
|
+
const cleanupStaleTmuxPrefix =
|
|
6963
|
+
readCliOptionValue(cmdArgs, "--prefix") || DEFAULT_TMUX_CLEANUP_PREFIX;
|
|
6964
|
+
const cleanupStaleTmuxAgeMin = parsePositiveIntegerOption(
|
|
6965
|
+
cmdArgs,
|
|
6966
|
+
"--age-min",
|
|
6967
|
+
DEFAULT_TMUX_CLEANUP_AGE_MIN,
|
|
6968
|
+
);
|
|
6541
6969
|
await cmdDoctor({
|
|
6542
6970
|
fix,
|
|
6543
6971
|
reset,
|
|
@@ -6545,6 +6973,11 @@ async function main() {
|
|
|
6545
6973
|
cleanupStaleHubs,
|
|
6546
6974
|
cleanupStaleHubsDryRun: cleanupDryRun,
|
|
6547
6975
|
cleanupStaleHubsApply: cleanupApply,
|
|
6976
|
+
cleanupStaleTmux,
|
|
6977
|
+
cleanupStaleTmuxDryRun: cleanupDryRun,
|
|
6978
|
+
cleanupStaleTmuxApply: cleanupApply,
|
|
6979
|
+
cleanupStaleTmuxPrefix,
|
|
6980
|
+
cleanupStaleTmuxAgeMin,
|
|
6548
6981
|
json: JSON_OUTPUT,
|
|
6549
6982
|
});
|
|
6550
6983
|
return;
|
|
@@ -343,6 +343,23 @@ function recordRouteOutcome(slug, mode, outcome) {
|
|
|
343
343
|
writeFileSync(weightsPath, JSON.stringify(weights, null, 2), "utf8");
|
|
344
344
|
}
|
|
345
345
|
|
|
346
|
+
function isHubTokenMonitorSnapshot(snapshot) {
|
|
347
|
+
if (!snapshot || typeof snapshot !== "object") return false;
|
|
348
|
+
return (
|
|
349
|
+
Object.hasOwn(snapshot, "requestTokens") ||
|
|
350
|
+
Object.hasOwn(snapshot, "responseTokens") ||
|
|
351
|
+
Object.hasOwn(snapshot, "totalUpdates") ||
|
|
352
|
+
(snapshot.byTool && typeof snapshot.byTool === "object")
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function readCompactNudgePercent(snapshot) {
|
|
357
|
+
if (isHubTokenMonitorSnapshot(snapshot)) return null;
|
|
358
|
+
const percent = Number(snapshot?.percent ?? 0);
|
|
359
|
+
if (!Number.isFinite(percent)) return null;
|
|
360
|
+
return Math.max(0, Math.min(100, percent));
|
|
361
|
+
}
|
|
362
|
+
|
|
346
363
|
// ── 메인 ────────────────────────────────────────────────────
|
|
347
364
|
async function main() {
|
|
348
365
|
// CLI: --record-route <slug> <mode> <outcome>
|
|
@@ -517,8 +534,8 @@ async function main() {
|
|
|
517
534
|
const nudgeMarker = join(tmpdir(), "tfx-compact-nudge-sent");
|
|
518
535
|
if (existsSync(snapshotPath) && !existsSync(nudgeMarker)) {
|
|
519
536
|
const snap = JSON.parse(readFileSync(snapshotPath, "utf8"));
|
|
520
|
-
const percent =
|
|
521
|
-
if (percent >= 80) {
|
|
537
|
+
const percent = readCompactNudgePercent(snap);
|
|
538
|
+
if (percent != null && percent >= 80) {
|
|
522
539
|
const level = percent >= 90 ? "critical" : "warn";
|
|
523
540
|
const msg =
|
|
524
541
|
level === "critical"
|
package/hooks/pipeline-stop.mjs
CHANGED
|
@@ -228,6 +228,16 @@ function contextPercentFromTranscript(payload) {
|
|
|
228
228
|
return percents.length > 0 ? Math.max(...percents) : null;
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
+
function isHubTokenMonitorSnapshot(snapshot) {
|
|
232
|
+
if (!snapshot || typeof snapshot !== "object") return false;
|
|
233
|
+
return (
|
|
234
|
+
Object.hasOwn(snapshot, "requestTokens") ||
|
|
235
|
+
Object.hasOwn(snapshot, "responseTokens") ||
|
|
236
|
+
Object.hasOwn(snapshot, "totalUpdates") ||
|
|
237
|
+
(snapshot.byTool && typeof snapshot.byTool === "object")
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
231
241
|
function contextPercentFromCache() {
|
|
232
242
|
const home = process.env.HOME || process.env.USERPROFILE;
|
|
233
243
|
if (!home) return null;
|
|
@@ -243,6 +253,7 @@ function contextPercentFromCache() {
|
|
|
243
253
|
try {
|
|
244
254
|
const parsed = parseJsonCandidate(readFileSync(cachePath, "utf8"));
|
|
245
255
|
if (!parsed) return null;
|
|
256
|
+
if (isHubTokenMonitorSnapshot(parsed)) return null;
|
|
246
257
|
const direct = contextPercentFromPayload(parsed);
|
|
247
258
|
if (direct !== null) return direct;
|
|
248
259
|
const percents = contextPercentsFromObject(parsed);
|
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
|
|
2
3
|
// hooks/session-end-cleanup.mjs — SessionEnd lifecycle state reporter
|
|
3
4
|
//
|
|
4
5
|
// Report-only by default. Opt-in cleanup only removes the repo-local
|
|
5
6
|
// .triflux/subagents/subagents.json file when stale running lifecycle state is
|
|
6
7
|
// present. It never kills processes or touches external HOME/MCP configs.
|
|
7
8
|
|
|
9
|
+
import { execFileSync } from "node:child_process";
|
|
8
10
|
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
9
11
|
import { join } from "node:path";
|
|
10
12
|
|
|
11
13
|
const MARKER = "[session-end-cleanup]";
|
|
12
14
|
const STALE_MS = 30 * 60 * 1000;
|
|
13
15
|
const MAX_OUTPUT_BYTES = 2 * 1024;
|
|
16
|
+
const DEFAULT_TMUX_PREFIX = "tfx-*";
|
|
17
|
+
const MAX_TMUX_REPORT_SESSIONS = 8;
|
|
14
18
|
|
|
15
19
|
function readStdin() {
|
|
16
20
|
try {
|
|
@@ -65,6 +69,140 @@ function agentEntries(state) {
|
|
|
65
69
|
return [];
|
|
66
70
|
}
|
|
67
71
|
|
|
72
|
+
function compileSimpleGlob(pattern) {
|
|
73
|
+
const source = String(pattern || DEFAULT_TMUX_PREFIX)
|
|
74
|
+
.replace(/[.+?^${}()|[\]\\]/g, "\\$&")
|
|
75
|
+
.replace(/\*/g, ".*");
|
|
76
|
+
return new RegExp(`^${source}$`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function formatAge(ageSec) {
|
|
80
|
+
if (!Number.isFinite(ageSec) || ageSec < 0) return "unknown";
|
|
81
|
+
if (ageSec < 60) return `${Math.floor(ageSec)}s`;
|
|
82
|
+
if (ageSec < 3600) return `${Math.floor(ageSec / 60)}m`;
|
|
83
|
+
if (ageSec < 86400) return `${Math.floor(ageSec / 3600)}h`;
|
|
84
|
+
return `${Math.floor(ageSec / 86400)}d`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function parseSessionCreated(rawValue) {
|
|
88
|
+
const parsed = Number.parseInt(String(rawValue || "").trim(), 10);
|
|
89
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function parseTmuxListSessions(output, nowSec) {
|
|
93
|
+
const sessions = [];
|
|
94
|
+
for (const line of String(output || "").split(/\r?\n/)) {
|
|
95
|
+
const [name, attachedText, createdText] = line.split("\t");
|
|
96
|
+
if (!name) continue;
|
|
97
|
+
const attached = Number.parseInt(attachedText || "0", 10);
|
|
98
|
+
const created = parseSessionCreated(createdText);
|
|
99
|
+
sessions.push({
|
|
100
|
+
name,
|
|
101
|
+
attached: Number.isFinite(attached) ? attached : 0,
|
|
102
|
+
ageSec: created == null ? null : Math.max(0, nowSec - created),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return sessions;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function parseFirstPane(output) {
|
|
109
|
+
const first = String(output || "")
|
|
110
|
+
.split(/\r?\n/)
|
|
111
|
+
.find((line) => line.trim());
|
|
112
|
+
if (!first) return { cwd: null, command: null, pid: null };
|
|
113
|
+
const [cwd, command, pidText] = first.split("\t");
|
|
114
|
+
const pid = Number.parseInt(pidText || "", 10);
|
|
115
|
+
return {
|
|
116
|
+
cwd: cwd || null,
|
|
117
|
+
command: command || null,
|
|
118
|
+
pid: Number.isFinite(pid) && pid > 0 ? pid : null,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function queryMemoryEstimateMb(pid, execFile = execFileSync) {
|
|
123
|
+
if (!pid) return null;
|
|
124
|
+
try {
|
|
125
|
+
const output = execFile("ps", ["-o", "rss=", "-p", String(pid)], {
|
|
126
|
+
encoding: "utf8",
|
|
127
|
+
timeout: 400,
|
|
128
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
129
|
+
windowsHide: true,
|
|
130
|
+
});
|
|
131
|
+
const rssKb = Number.parseInt(String(output || "").trim(), 10);
|
|
132
|
+
if (!Number.isFinite(rssKb) || rssKb < 0) return null;
|
|
133
|
+
return Math.round(rssKb / 1024);
|
|
134
|
+
} catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function inspectDetachedTmuxSessions({
|
|
140
|
+
prefix = DEFAULT_TMUX_PREFIX,
|
|
141
|
+
now = Date.now(),
|
|
142
|
+
execFile = execFileSync,
|
|
143
|
+
limit = MAX_TMUX_REPORT_SESSIONS,
|
|
144
|
+
} = {}) {
|
|
145
|
+
const nowSec = Math.floor(now / 1000);
|
|
146
|
+
const matcher = compileSimpleGlob(prefix);
|
|
147
|
+
let output = "";
|
|
148
|
+
try {
|
|
149
|
+
output = execFile(
|
|
150
|
+
"tmux",
|
|
151
|
+
[
|
|
152
|
+
"list-sessions",
|
|
153
|
+
"-F",
|
|
154
|
+
"#{session_name}\t#{session_attached}\t#{session_created}",
|
|
155
|
+
],
|
|
156
|
+
{
|
|
157
|
+
encoding: "utf8",
|
|
158
|
+
timeout: 500,
|
|
159
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
160
|
+
windowsHide: true,
|
|
161
|
+
},
|
|
162
|
+
);
|
|
163
|
+
} catch {
|
|
164
|
+
return { available: false, prefix, sessions: [] };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const sessions = [];
|
|
168
|
+
for (const session of parseTmuxListSessions(output, nowSec)) {
|
|
169
|
+
if (session.attached !== 0 || !matcher.test(session.name)) continue;
|
|
170
|
+
let pane = { cwd: null, command: null, pid: null };
|
|
171
|
+
try {
|
|
172
|
+
pane = parseFirstPane(
|
|
173
|
+
execFile(
|
|
174
|
+
"tmux",
|
|
175
|
+
[
|
|
176
|
+
"list-panes",
|
|
177
|
+
"-t",
|
|
178
|
+
session.name,
|
|
179
|
+
"-F",
|
|
180
|
+
"#{pane_current_path}\t#{pane_current_command}\t#{pane_pid}",
|
|
181
|
+
],
|
|
182
|
+
{
|
|
183
|
+
encoding: "utf8",
|
|
184
|
+
timeout: 500,
|
|
185
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
186
|
+
windowsHide: true,
|
|
187
|
+
},
|
|
188
|
+
),
|
|
189
|
+
);
|
|
190
|
+
} catch {
|
|
191
|
+
pane = { cwd: null, command: null, pid: null };
|
|
192
|
+
}
|
|
193
|
+
sessions.push({
|
|
194
|
+
name: session.name,
|
|
195
|
+
age: formatAge(session.ageSec),
|
|
196
|
+
cwd: pane.cwd,
|
|
197
|
+
command: pane.command,
|
|
198
|
+
memory_estimate_mb: queryMemoryEstimateMb(pane.pid, execFile),
|
|
199
|
+
});
|
|
200
|
+
if (sessions.length >= limit) break;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return { available: true, prefix, sessions };
|
|
204
|
+
}
|
|
205
|
+
|
|
68
206
|
export function summarizeLifecycleState(state, now = Date.now()) {
|
|
69
207
|
const completedEntries = Array.isArray(state?.completed)
|
|
70
208
|
? state.completed
|
|
@@ -121,28 +259,31 @@ function pruneStaleRunningAgents(state, now = Date.now()) {
|
|
|
121
259
|
};
|
|
122
260
|
}
|
|
123
261
|
|
|
124
|
-
function
|
|
125
|
-
|
|
262
|
+
function buildContext(summary, cleanup, tmuxReport) {
|
|
263
|
+
let context =
|
|
126
264
|
`${MARKER} subagent lifecycle: ` +
|
|
127
265
|
`running=${summary.running} completed=${summary.completed} ` +
|
|
128
266
|
`stale=${summary.stale} total=${summary.total} cleanup=${cleanup}`;
|
|
267
|
+
|
|
268
|
+
if (tmuxReport?.sessions?.length > 0) {
|
|
269
|
+
context += ` detached_tmux_sessions=${JSON.stringify(tmuxReport.sessions)}`;
|
|
270
|
+
}
|
|
271
|
+
return context;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function buildOutput(summary, cleanup, tmuxReport) {
|
|
275
|
+
const context = buildContext(summary, cleanup, tmuxReport);
|
|
129
276
|
const output = {
|
|
130
277
|
systemMessage: context,
|
|
131
|
-
hookSpecificOutput: {
|
|
132
|
-
hookEventName: "SessionEnd",
|
|
133
|
-
additionalContext: context,
|
|
134
|
-
},
|
|
135
278
|
};
|
|
136
279
|
|
|
137
280
|
let json = JSON.stringify(output);
|
|
138
281
|
if (Buffer.byteLength(json, "utf8") >= MAX_OUTPUT_BYTES) {
|
|
139
|
-
const bounded =
|
|
282
|
+
const bounded = buildContext(summary, cleanup, {
|
|
283
|
+
sessions: tmuxReport?.sessions?.slice(0, 2) || [],
|
|
284
|
+
});
|
|
140
285
|
json = JSON.stringify({
|
|
141
286
|
systemMessage: bounded,
|
|
142
|
-
hookSpecificOutput: {
|
|
143
|
-
hookEventName: "SessionEnd",
|
|
144
|
-
additionalContext: bounded,
|
|
145
|
-
},
|
|
146
287
|
});
|
|
147
288
|
}
|
|
148
289
|
return json;
|
|
@@ -160,6 +301,9 @@ export function run(input, env = process.env) {
|
|
|
160
301
|
|
|
161
302
|
const summary = summarizeLifecycleState(state);
|
|
162
303
|
let cleanup = "report-only";
|
|
304
|
+
const tmuxReport = inspectDetachedTmuxSessions({
|
|
305
|
+
prefix: env.TFX_SESSION_END_TMUX_PREFIX || DEFAULT_TMUX_PREFIX,
|
|
306
|
+
});
|
|
163
307
|
|
|
164
308
|
if (env.TFX_SESSION_END_CLEANUP === "1" && summary.stale > 0) {
|
|
165
309
|
try {
|
|
@@ -184,7 +328,7 @@ export function run(input, env = process.env) {
|
|
|
184
328
|
}
|
|
185
329
|
}
|
|
186
330
|
|
|
187
|
-
return buildOutput(summary, cleanup);
|
|
331
|
+
return buildOutput(summary, cleanup, tmuxReport);
|
|
188
332
|
}
|
|
189
333
|
|
|
190
334
|
function main() {
|
package/hub/team/session.mjs
CHANGED
|
@@ -135,11 +135,11 @@ export function detectMultiplexer() {
|
|
|
135
135
|
_cachedMux = "tmux";
|
|
136
136
|
return _cachedMux;
|
|
137
137
|
}
|
|
138
|
-
if (
|
|
138
|
+
if (hasGitBashTmux()) {
|
|
139
139
|
_cachedMux = "git-bash-tmux";
|
|
140
140
|
return _cachedMux;
|
|
141
141
|
}
|
|
142
|
-
if (
|
|
142
|
+
if (hasWslTmux()) {
|
|
143
143
|
_cachedMux = "wsl-tmux";
|
|
144
144
|
return _cachedMux;
|
|
145
145
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { exec as defaultExec,
|
|
1
|
+
import { exec as defaultExec, spawnSync } from "node:child_process";
|
|
2
2
|
import { platform as osPlatform } from "node:os";
|
|
3
3
|
import { psmuxExec as defaultPsmuxExec } from "./psmux.mjs";
|
|
4
4
|
import { tmuxExec as defaultTmuxExec, detectMultiplexer } from "./session.mjs";
|
|
@@ -68,15 +68,27 @@ function shellCommandName(value) {
|
|
|
68
68
|
return /^[A-Za-z0-9_./:-]+$/u.test(command) ? command : shellQuote(command);
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
function powershellCommandName(value) {
|
|
72
|
+
const command = String(value);
|
|
73
|
+
return /^[A-Za-z0-9_.:-]+$/u.test(command)
|
|
74
|
+
? command
|
|
75
|
+
: `& ${powershellSingleQuote(command)}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function getCommandVersion(command) {
|
|
79
|
+
const r = spawnSync(command, ["-V"], {
|
|
80
|
+
encoding: "utf8",
|
|
81
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
82
|
+
timeout: 3000,
|
|
83
|
+
windowsHide: true,
|
|
84
|
+
});
|
|
85
|
+
if ((r.status ?? 1) !== 0) return null;
|
|
86
|
+
return `${r.stdout || ""}${r.stderr || ""}`.trim();
|
|
87
|
+
}
|
|
88
|
+
|
|
71
89
|
function defaultPsmuxBinaryExists(command) {
|
|
72
90
|
try {
|
|
73
|
-
|
|
74
|
-
encoding: "utf8",
|
|
75
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
76
|
-
timeout: 3000,
|
|
77
|
-
windowsHide: true,
|
|
78
|
-
});
|
|
79
|
-
return /\bpsmux\b/i.test(String(output || ""));
|
|
91
|
+
return /\bpsmux\b/i.test(getCommandVersion(command) || "");
|
|
80
92
|
} catch {
|
|
81
93
|
return false;
|
|
82
94
|
}
|
|
@@ -139,12 +151,18 @@ export function createTerminalOpener(deps = {}) {
|
|
|
139
151
|
|
|
140
152
|
async function openSession(sessionName, opts = {}) {
|
|
141
153
|
if (platform === "win32") {
|
|
154
|
+
const command = process.env.PSMUX_BIN || "psmux";
|
|
155
|
+
const psmuxBinaryExists =
|
|
156
|
+
deps.psmuxBinaryExists || defaultPsmuxBinaryExists;
|
|
157
|
+
if (!psmuxBinaryExists(command)) return false;
|
|
142
158
|
const wt = createWtManager();
|
|
143
159
|
try {
|
|
144
160
|
return wtResultSucceeded(
|
|
145
161
|
await wt.createTab({
|
|
146
162
|
title: sanitizeTerminalTitle(opts.title ?? sessionName),
|
|
147
|
-
command:
|
|
163
|
+
command: `${powershellCommandName(
|
|
164
|
+
command,
|
|
165
|
+
)} attach-session -t ${powershellSingleQuote(sessionName)}`,
|
|
148
166
|
cwd: opts.cwd,
|
|
149
167
|
profile: opts.profile ?? "triflux",
|
|
150
168
|
}),
|