@triflux/remote 10.53.0 → 10.55.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/hub/team/backend.mjs +10 -3
- package/hub/team/cli/commands/start/index.mjs +0 -2
- package/hub/team/handoff.mjs +1 -1
- package/hub/team/headless.mjs +41 -57
- package/hub/team/intervention.mjs +6 -4
- package/hub/team/nativeProxy.mjs +2 -48
- package/hub/team/pane.mjs +31 -18
- package/hub/team/psmux.mjs +36 -20
- package/hub/team/remote-session.mjs +66 -56
- package/hub/team/retry-state-machine.mjs +1 -1
- package/hub/team/session.mjs +80 -59
- package/hub/team/terminal-opener.mjs +5 -19
- package/hub/team/tui-viewer.mjs +2 -2
- package/hub/team/tui.mjs +0 -6
- package/hub/workers/factory.mjs +1 -10
- package/hub/workers/interface.mjs +0 -7
- package/package.json +2 -6
- package/scripts/lib/backup-rotation.mjs +60 -0
- package/scripts/lib/codex-profile-config.mjs +20 -7
- package/scripts/lib/codex-recovery.sh +2 -3
- package/scripts/lib/install-blob-history.json +3196 -0
- package/scripts/lib/install-retire.mjs +203 -0
- package/scripts/lib/legacy-hook-cleanup.mjs +148 -13
- package/scripts/lib/legacy-mcp-cleanup.mjs +405 -104
- package/scripts/lib/machine-profile.mjs +2 -2
- package/scripts/lib/mcp-filter.mjs +1 -42
- package/scripts/lib/mcp-guard-engine.mjs +198 -19
- package/scripts/lib/mcp-manifest.mjs +0 -40
- package/scripts/lib/process-utils.mjs +19 -0
- package/scripts/lib/stable-node.mjs +35 -0
- package/scripts/lib/test-env.mjs +20 -0
- package/cto/brief.mjs +0 -73
- package/cto/collect.mjs +0 -490
- package/cto/current.schema.json +0 -148
- package/cto/events.mjs +0 -208
- package/cto/hygiene.mjs +0 -320
- package/cto/lake-root.mjs +0 -127
- package/cto/status.mjs +0 -124
- package/hub/lib/cto-env.mjs +0 -1
- package/hub/team/notify.mjs +0 -385
- package/hub/team/process-cleanup.mjs +0 -386
- package/hub/team/routing.mjs +0 -249
- package/hub/team/staleState.mjs +0 -403
- package/hub/team/worker-signal.mjs +0 -263
- package/hub/team/worker-signal.types.d.ts +0 -52
- package/hub/workers/codex-mcp.mjs +0 -853
- package/scripts/lib/remote-spawn-transfer.mjs +0 -226
package/hub/team/backend.mjs
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
|
+
import { writePromptToTmpFile } from "@triflux/core/hub/lib/prompt-tmp.mjs";
|
|
1
2
|
import { IS_WINDOWS } from "@triflux/core/hub/platform.mjs";
|
|
2
3
|
import { powershellSingleQuote, shellQuote } from "./terminal-opener.mjs";
|
|
3
4
|
|
|
4
5
|
class ClaudeBackend {
|
|
5
|
-
|
|
6
|
+
// 프롬프트를 argv 로 넘기면 ps 에 보인다. claude --print 는 프롬프트 인자가 없으면 stdin 을 읽는다.
|
|
7
|
+
buildArgs(prompt, resultFile, { isWindows = IS_WINDOWS, promptFile } = {}) {
|
|
8
|
+
const file = promptFile || writePromptToTmpFile(prompt);
|
|
6
9
|
const quote = isWindows ? powershellSingleQuote : shellQuote;
|
|
7
|
-
const
|
|
8
|
-
|
|
10
|
+
const run = `claude --print --output-format text > ${quote(resultFile)} 2>&1`;
|
|
11
|
+
// PowerShell 5.1 은 BOM 없는 파일을 ANSI 로 읽고 파이프를 ASCII 로 보내서 UTF-8 을 명시한다.
|
|
12
|
+
// -LiteralPath 는 경로의 [] 를 와일드카드로 읽지 않게 한다.
|
|
13
|
+
return isWindows
|
|
14
|
+
? `$OutputEncoding = [System.Text.UTF8Encoding]::new($false); Get-Content -LiteralPath ${quote(file)} -Raw -Encoding UTF8 | ${run}`
|
|
15
|
+
: `${run} < ${quote(file)}`;
|
|
9
16
|
}
|
|
10
17
|
}
|
|
11
18
|
|
package/hub/team/handoff.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// hub/team/handoff.mjs — Worker → Lead handoff 프로토콜
|
|
2
2
|
// HANDOFF 블록 파싱, 검증, fallback 생성, Lead 포맷팅
|
|
3
|
-
// 설계: docs/design/handoff-schema-v7.md
|
|
3
|
+
// 설계: docs/design/handoff-schema-v7.md (PR #70 에서 삭제, c0722ecd 에 남아 있다)
|
|
4
4
|
|
|
5
5
|
// ── enum 허용 값 ──
|
|
6
6
|
const STATUS_VALUES = ["ok", "partial", "failed"];
|
package/hub/team/headless.mjs
CHANGED
|
@@ -5,18 +5,17 @@ import { execSync } from "node:child_process";
|
|
|
5
5
|
import { randomUUID } from "node:crypto";
|
|
6
6
|
import {
|
|
7
7
|
existsSync,
|
|
8
|
-
mkdirSync,
|
|
9
8
|
readFileSync,
|
|
10
9
|
rmSync,
|
|
11
10
|
statSync,
|
|
12
11
|
writeFileSync,
|
|
13
12
|
} from "node:fs";
|
|
14
13
|
import { createRequire } from "node:module";
|
|
15
|
-
import {
|
|
16
|
-
import { dirname, join, resolve } from "node:path";
|
|
14
|
+
import { delimiter, dirname, isAbsolute, join, resolve } from "node:path";
|
|
17
15
|
import { fileURLToPath } from "node:url";
|
|
18
16
|
import { resolveNestedCodexAgentProfile } from "../../scripts/lib/agent-route-policy.mjs";
|
|
19
17
|
import { escapePwshSingleQuoted } from "@triflux/core/hub/cli-adapter-base.mjs";
|
|
18
|
+
import { privateTmpDir } from "@triflux/core/hub/lib/private-tmp.mjs";
|
|
20
19
|
import {
|
|
21
20
|
createActivityLifecycle,
|
|
22
21
|
isActivityLifecycleEnabled,
|
|
@@ -47,7 +46,10 @@ import { createResultsIndex, resultsIndexPath } from "./results-index.mjs";
|
|
|
47
46
|
import { createLogDashboard } from "./tui.mjs";
|
|
48
47
|
import { createWtManager } from "./wt-manager.mjs";
|
|
49
48
|
|
|
50
|
-
|
|
49
|
+
// 결과와 프롬프트, 실행 스크립트가 남으므로 사용자별 0700 디렉터리에 둔다.
|
|
50
|
+
function resultDir() {
|
|
51
|
+
return privateTmpDir("tfx-headless");
|
|
52
|
+
}
|
|
51
53
|
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
|
52
54
|
|
|
53
55
|
/** CLI별 브랜드 — 이모지 + 공식 색상 (HUD와 통일) */
|
|
@@ -85,27 +87,37 @@ function shellQuote(value) {
|
|
|
85
87
|
return `'${String(value).replace(/'/g, "'\\''")}'`;
|
|
86
88
|
}
|
|
87
89
|
|
|
90
|
+
// 빈 항목과 상대 경로는 cwd 를 뜻하므로 건너뛴다.
|
|
91
|
+
function findOnPath(name) {
|
|
92
|
+
for (const dir of String(process.env.PATH || "").split(delimiter)) {
|
|
93
|
+
if (!dir || !isAbsolute(dir)) continue;
|
|
94
|
+
const candidate = join(dir, name);
|
|
95
|
+
if (existsSync(candidate)) return candidate;
|
|
96
|
+
}
|
|
97
|
+
return "";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// cwd 의 스크립트는 후보에 넣지 않는다. 신뢰하지 않는 저장소에서 돌리면 그 저장소의 스크립트가
|
|
101
|
+
// 실행된다. bash 에 맨 이름만 넘겨도 cwd 를 먼저 읽으므로 찾지 못하면 실행하지 않는다.
|
|
88
102
|
function resolveHeadlessRouteScript(opts = {}) {
|
|
103
|
+
if (opts.routeScript) return opts.routeScript;
|
|
89
104
|
const candidates = [
|
|
90
|
-
opts.routeScript,
|
|
91
105
|
process.env.TFX_ROUTE_SCRIPT,
|
|
92
106
|
process.env.TFX_DELEGATOR_ROUTE_SCRIPT,
|
|
93
|
-
join(process.cwd(), "scripts", "tfx-route.sh"),
|
|
94
107
|
join(SCRIPT_DIR, "..", "..", "scripts", "tfx-route.sh"),
|
|
95
108
|
process.env.HOME
|
|
96
109
|
? join(process.env.HOME, ".claude", "scripts", "tfx-route.sh")
|
|
97
110
|
: "",
|
|
98
|
-
"tfx-route.sh",
|
|
99
111
|
];
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
112
|
+
const found =
|
|
113
|
+
candidates.find((candidate) => candidate && existsSync(candidate)) ||
|
|
114
|
+
findOnPath("tfx-route.sh");
|
|
115
|
+
if (!found) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
"tfx-route.sh 를 찾지 못했다. TFX_ROUTE_SCRIPT 로 경로를 지정한다.",
|
|
118
|
+
);
|
|
106
119
|
}
|
|
107
|
-
|
|
108
|
-
return "tfx-route.sh";
|
|
120
|
+
return found;
|
|
109
121
|
}
|
|
110
122
|
|
|
111
123
|
function resolveRouteAgentForHeadless(resolvedCli, opts = {}) {
|
|
@@ -128,11 +140,14 @@ export function normalizeHeadlessRole(role) {
|
|
|
128
140
|
}
|
|
129
141
|
|
|
130
142
|
export function headlessResultFile(sessionName, paneName) {
|
|
131
|
-
return join(
|
|
143
|
+
return join(resultDir(), `${sessionName}-${paneName}.txt`).replace(
|
|
144
|
+
/\\/g,
|
|
145
|
+
"/",
|
|
146
|
+
);
|
|
132
147
|
}
|
|
133
148
|
|
|
134
149
|
export function headlessResultsIndexPath(sessionName) {
|
|
135
|
-
return resultsIndexPath(
|
|
150
|
+
return resultsIndexPath(resultDir(), sessionName).replace(/\\/g, "/");
|
|
136
151
|
}
|
|
137
152
|
|
|
138
153
|
export function resolveHeadlessDisplayName(assignment = {}, paneName = "") {
|
|
@@ -200,25 +215,6 @@ const MCP_PROFILE_HINTS = {
|
|
|
200
215
|
docs: "Focus on documentation and explanation tasks.",
|
|
201
216
|
};
|
|
202
217
|
|
|
203
|
-
export function buildDashboardAttachArgs(
|
|
204
|
-
sessionName,
|
|
205
|
-
layout,
|
|
206
|
-
workerCount,
|
|
207
|
-
anchor = "window",
|
|
208
|
-
) {
|
|
209
|
-
const safeName = String(sessionName).replace(/[^a-zA-Z0-9_-]/g, "");
|
|
210
|
-
const base = anchor === "tab" ? ["-w", "0", "nt"] : ["-w", "new"];
|
|
211
|
-
return [
|
|
212
|
-
...base,
|
|
213
|
-
"--session",
|
|
214
|
-
safeName,
|
|
215
|
-
"--layout",
|
|
216
|
-
layout,
|
|
217
|
-
"--workers",
|
|
218
|
-
String(workerCount),
|
|
219
|
-
];
|
|
220
|
-
}
|
|
221
|
-
|
|
222
218
|
export function buildHeadlessCommand(cli, prompt, resultFile, opts = {}) {
|
|
223
219
|
const { handoff = true, mcp, contextFile, model, cwd } = opts;
|
|
224
220
|
const resolvedCli = resolveCliType(cli);
|
|
@@ -256,15 +252,9 @@ export function buildHeadlessCommand(cli, prompt, resultFile, opts = {}) {
|
|
|
256
252
|
const handoffHint = handoff ? `\n\n${HANDOFF_INSTRUCTION_SHORT}` : "";
|
|
257
253
|
const fullPrompt = `${contextPrefix}${prompt}${mcpHint}${workspaceHint}${handoffHint}`;
|
|
258
254
|
|
|
259
|
-
// 보안: 프롬프트를 임시 파일에 쓰고 파일 참조로
|
|
260
|
-
// 이전 구현은 `"$(cat 'PATH')"` shell-expansion expression 을 만들어
|
|
261
|
-
// backend.buildArgs 에 전달했는데, 새 stdin-prompt 모드 (PR #252) 가
|
|
262
|
-
// 그 literal 을 user message 로 해석해서 codex 가 prompt 가 아닌
|
|
263
|
-
// shell expression 자체를 받는 회귀가 있었다. 이제 fullPrompt 를 그대로
|
|
264
|
-
// 전달하고 buildExecCommand 가 자체적으로 tmp file 작성 + stdin redirect.
|
|
265
|
-
if (!existsSync(RESULT_DIR)) mkdirSync(RESULT_DIR, { recursive: true });
|
|
255
|
+
// 보안: 프롬프트를 임시 파일에 쓰고 파일 참조로 전달한다(셸 주입 방지).
|
|
266
256
|
const promptFile = join(
|
|
267
|
-
|
|
257
|
+
resultDir(),
|
|
268
258
|
"prompt-" + randomUUID().slice(0, 8) + ".txt",
|
|
269
259
|
).replace(/\\/g, "/");
|
|
270
260
|
writeFileSync(promptFile, fullPrompt, "utf8");
|
|
@@ -655,7 +645,7 @@ async function dispatchBatch(sessionName, assignments, opts = {}) {
|
|
|
655
645
|
cwd: assignment.cwd || assignment.workdir,
|
|
656
646
|
},
|
|
657
647
|
);
|
|
658
|
-
const scriptDir = join(
|
|
648
|
+
const scriptDir = join(resultDir(), sessionName);
|
|
659
649
|
const dispatch = dispatchCommand(sessionName, paneName, cmd, {
|
|
660
650
|
scriptDir,
|
|
661
651
|
scriptName: paneName,
|
|
@@ -912,7 +902,7 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
|
912
902
|
onIntervene,
|
|
913
903
|
};
|
|
914
904
|
|
|
915
|
-
|
|
905
|
+
resultDir();
|
|
916
906
|
const normalizedAssignments = (assignments || []).map((assignment, index) =>
|
|
917
907
|
normalizeHeadlessAssignment(assignment, index),
|
|
918
908
|
);
|
|
@@ -970,7 +960,7 @@ export async function runHeadless(sessionName, assignments, opts = {}) {
|
|
|
970
960
|
}
|
|
971
961
|
} else if (dashboard) {
|
|
972
962
|
// stdout은 결과 파싱 대상이므로 안내는 stderr로 보낸다.
|
|
973
|
-
const logDir =
|
|
963
|
+
const logDir = resultDir();
|
|
974
964
|
process.stderr.write(
|
|
975
965
|
`\n⚠ stdout is not a TTY; dashboard is skipped.\n` +
|
|
976
966
|
` Session is running in background. Worker logs:\n` +
|
|
@@ -1446,14 +1436,11 @@ export async function autoAttachTerminal(
|
|
|
1446
1436
|
opts.dashboardLayout || "single",
|
|
1447
1437
|
workerCount,
|
|
1448
1438
|
);
|
|
1449
|
-
const viewerPath = join(
|
|
1450
|
-
/\\/g,
|
|
1451
|
-
"/",
|
|
1452
|
-
);
|
|
1439
|
+
const viewerPath = join(SCRIPT_DIR, "tui-viewer.mjs").replace(/\\/g, "/");
|
|
1453
1440
|
await wt.createTab({
|
|
1454
1441
|
title: buildAttachTitle(safeSession, "dashboard"),
|
|
1455
1442
|
profile: "triflux",
|
|
1456
|
-
command: `node "${viewerPath}" --session ${safeSession} --result-dir "${
|
|
1443
|
+
command: `node "${viewerPath}" --session ${safeSession} --result-dir "${resultDir()}" --layout ${resolvedLayout}`,
|
|
1457
1444
|
});
|
|
1458
1445
|
} else {
|
|
1459
1446
|
const panes = [];
|
|
@@ -1519,15 +1506,12 @@ export async function attachDashboardTab(
|
|
|
1519
1506
|
try {
|
|
1520
1507
|
const safeSession = sanitizeSessionName(sessionName);
|
|
1521
1508
|
const resolvedLayout = resolveDashboardLayout(dashboardLayout, workerCount);
|
|
1522
|
-
const viewerPath = join(
|
|
1523
|
-
/\\/g,
|
|
1524
|
-
"/",
|
|
1525
|
-
);
|
|
1509
|
+
const viewerPath = join(SCRIPT_DIR, "tui-viewer.mjs").replace(/\\/g, "/");
|
|
1526
1510
|
|
|
1527
1511
|
await wt.createTab({
|
|
1528
1512
|
title: buildAttachTitle(safeSession, "dashboard"),
|
|
1529
1513
|
profile: "triflux",
|
|
1530
|
-
command: `node "${viewerPath}" --session ${safeSession} --result-dir "${
|
|
1514
|
+
command: `node "${viewerPath}" --session ${safeSession} --result-dir "${resultDir()}" --layout ${resolvedLayout}`,
|
|
1531
1515
|
});
|
|
1532
1516
|
return true;
|
|
1533
1517
|
} catch {
|
|
@@ -125,10 +125,12 @@ export async function resolveCodexRolloutFile({
|
|
|
125
125
|
return null;
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
|
|
128
|
+
// 프롬프트는 argv 에 두면 ps 에 보여서 넣지 않는다. 실행하는 쪽이 prompt 를 stdin 으로 넘긴다.
|
|
129
|
+
// claude -p 는 프롬프트 인자가 없으면, codex exec resume 은 `-` 를 받으면 stdin 을 읽는다.
|
|
130
|
+
export function buildResumeArgv({ cli, sessionId } = {}) {
|
|
129
131
|
if (!sessionId) return null;
|
|
130
|
-
if (cli === "claude") return ["claude", "-p", "--resume", sessionId
|
|
131
|
-
if (cli === "codex") return ["codex", "exec", "resume", sessionId,
|
|
132
|
+
if (cli === "claude") return ["claude", "-p", "--resume", sessionId];
|
|
133
|
+
if (cli === "codex") return ["codex", "exec", "resume", sessionId, "-"];
|
|
132
134
|
return null;
|
|
133
135
|
}
|
|
134
136
|
|
|
@@ -474,7 +476,7 @@ export function createInterventionLadder({
|
|
|
474
476
|
sessionId,
|
|
475
477
|
prompt: reinstructPrompt,
|
|
476
478
|
target,
|
|
477
|
-
argv: buildResumeArgv({ cli, sessionId
|
|
479
|
+
argv: buildResumeArgv({ cli, sessionId }),
|
|
478
480
|
});
|
|
479
481
|
return resumed?.ok === false
|
|
480
482
|
? { ok: false, detail: "resume_dispatch_failed" }
|
package/hub/team/nativeProxy.mjs
CHANGED
|
@@ -6,7 +6,6 @@ import {
|
|
|
6
6
|
existsSync,
|
|
7
7
|
mkdirSync,
|
|
8
8
|
renameSync,
|
|
9
|
-
rmSync,
|
|
10
9
|
unlinkSync,
|
|
11
10
|
writeFileSync,
|
|
12
11
|
} from "node:fs";
|
|
@@ -23,7 +22,8 @@ import { isPidAlive } from "@triflux/core/hub/lib/process-utils.mjs";
|
|
|
23
22
|
import { IS_WINDOWS } from "@triflux/core/hub/platform.mjs";
|
|
24
23
|
|
|
25
24
|
const TEAM_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
26
|
-
|
|
25
|
+
// Claude Code 와 같은 규칙으로 CLAUDE_CONFIG_DIR 를 따른다. 테스트도 이걸로 실제 ~/.claude 를 피한다.
|
|
26
|
+
const CLAUDE_HOME = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
|
|
27
27
|
const TEAMS_ROOT = join(CLAUDE_HOME, "teams");
|
|
28
28
|
const TASKS_ROOT = join(CLAUDE_HOME, "tasks");
|
|
29
29
|
const LOCK_STALE_MS = 30000;
|
|
@@ -232,52 +232,6 @@ export async function resolveTeamPaths(teamName) {
|
|
|
232
232
|
};
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
-
export async function forceCleanupTeam(teamName) {
|
|
236
|
-
validateTeamName(teamName);
|
|
237
|
-
|
|
238
|
-
let paths;
|
|
239
|
-
try {
|
|
240
|
-
paths = await resolveTeamPaths(teamName);
|
|
241
|
-
} catch {
|
|
242
|
-
paths = {
|
|
243
|
-
team_dir: join(TEAMS_ROOT, teamName),
|
|
244
|
-
config_path: join(TEAMS_ROOT, teamName, "config.json"),
|
|
245
|
-
tasks_dir: join(TASKS_ROOT, teamName),
|
|
246
|
-
lead_session_id: null,
|
|
247
|
-
config: null,
|
|
248
|
-
};
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
try {
|
|
252
|
-
const config = paths.config || (await readJsonSafe(paths.config_path));
|
|
253
|
-
if (config && Array.isArray(config.members)) {
|
|
254
|
-
atomicWriteJson(paths.config_path, {
|
|
255
|
-
...config,
|
|
256
|
-
members: config.members.map((member) => ({
|
|
257
|
-
...member,
|
|
258
|
-
isActive: false,
|
|
259
|
-
})),
|
|
260
|
-
});
|
|
261
|
-
}
|
|
262
|
-
} catch {}
|
|
263
|
-
|
|
264
|
-
const cleanupTargets = new Set([
|
|
265
|
-
paths.team_dir,
|
|
266
|
-
join(TASKS_ROOT, teamName),
|
|
267
|
-
paths.tasks_dir,
|
|
268
|
-
]);
|
|
269
|
-
if (paths.lead_session_id) {
|
|
270
|
-
cleanupTargets.add(join(TASKS_ROOT, paths.lead_session_id));
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
for (const targetPath of cleanupTargets) {
|
|
274
|
-
if (!targetPath) continue;
|
|
275
|
-
try {
|
|
276
|
-
rmSync(targetPath, { recursive: true, force: true });
|
|
277
|
-
} catch {}
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
|
|
281
235
|
async function collectTaskFiles(tasksDir) {
|
|
282
236
|
if (!existsSync(tasksDir)) return [];
|
|
283
237
|
|
package/hub/team/pane.mjs
CHANGED
|
@@ -1,15 +1,12 @@
|
|
|
1
1
|
// hub/team/pane.mjs — pane별 CLI 실행 + stdin 주입
|
|
2
2
|
// 의존성: child_process, fs, os, path (Node.js 내장)만 사용
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { unlinkSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
+
import { privateTmpDir } from "@triflux/core/hub/lib/private-tmp.mjs";
|
|
6
7
|
import { psmuxExec } from "./psmux.mjs";
|
|
7
8
|
import { detectMultiplexer, tmuxExec } from "./session.mjs";
|
|
8
9
|
|
|
9
|
-
function quoteArg(value) {
|
|
10
|
-
return `"${String(value).replace(/"/g, '\\"')}"`;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
10
|
function getPsmuxSessionName(target) {
|
|
14
11
|
return String(target).split(":")[0]?.trim() || "";
|
|
15
12
|
}
|
|
@@ -46,15 +43,26 @@ function muxExec(args, opts = {}) {
|
|
|
46
43
|
});
|
|
47
44
|
}
|
|
48
45
|
|
|
46
|
+
const AGENT_TO_CLI = createRequire(import.meta.url)("./agent-map.json");
|
|
47
|
+
|
|
48
|
+
// 역할명(designer)과 별칭(agy)을 CLI 종류로 푼다. 실행과 주입 판정이 같은 값을 본다.
|
|
49
|
+
export function resolveCli(cli) {
|
|
50
|
+
const name = String(cli || "").toLowerCase();
|
|
51
|
+
return AGENT_TO_CLI[name] ?? name;
|
|
52
|
+
}
|
|
53
|
+
|
|
49
54
|
/**
|
|
50
|
-
* CLI 에이전트 시작 커맨드
|
|
51
|
-
* @param {
|
|
55
|
+
* CLI 에이전트 시작 커맨드 생성. 역할명과 별칭은 agent-map 으로 CLI 를 고른다.
|
|
56
|
+
* @param {string} cli: CLI 이름, 별칭(agy) 또는 역할명
|
|
52
57
|
* @returns {string} 실행할 셸 커맨드
|
|
53
58
|
*/
|
|
54
59
|
export function buildCliCommand(cli) {
|
|
55
|
-
switch (cli) {
|
|
60
|
+
switch (resolveCli(cli)) {
|
|
56
61
|
case "codex":
|
|
57
62
|
return "codex --dangerously-bypass-approvals-and-sandbox";
|
|
63
|
+
case "antigravity":
|
|
64
|
+
// 실행 파일은 agy 다. antigravity 는 셸 별칭이라 비대화형 셸에 없다.
|
|
65
|
+
return "agy";
|
|
58
66
|
case "claude":
|
|
59
67
|
// interactive 모드
|
|
60
68
|
return "claude";
|
|
@@ -82,7 +90,7 @@ export function startCliInPane(target, command) {
|
|
|
82
90
|
* @param {{ multiplexer: string, useFileRef: boolean, cli: string|null }} args
|
|
83
91
|
*/
|
|
84
92
|
export function shouldUseFileRef({ multiplexer, useFileRef, cli }) {
|
|
85
|
-
return multiplexer === "psmux" && useFileRef && cli !== "codex";
|
|
93
|
+
return multiplexer === "psmux" && useFileRef && resolveCli(cli) !== "codex";
|
|
86
94
|
}
|
|
87
95
|
|
|
88
96
|
/** 동기 sleep — injectPrompt는 sync 경로라 setTimeout을 쓸 수 없다 */
|
|
@@ -105,7 +113,7 @@ function capturePaneText(target) {
|
|
|
105
113
|
"",
|
|
106
114
|
);
|
|
107
115
|
}
|
|
108
|
-
return String(muxExec(
|
|
116
|
+
return String(muxExec(["capture-pane", "-t", target, "-p"]) ?? "");
|
|
109
117
|
} catch {
|
|
110
118
|
return "";
|
|
111
119
|
}
|
|
@@ -136,7 +144,7 @@ function waitForComposerReady(target) {
|
|
|
136
144
|
* capture 불가 또는 재시도 소진을 성공으로 숨기지 않고 오류로 반환한다.
|
|
137
145
|
*/
|
|
138
146
|
function isAntigravityCli(cli) {
|
|
139
|
-
return
|
|
147
|
+
return resolveCli(cli) === "antigravity";
|
|
140
148
|
}
|
|
141
149
|
|
|
142
150
|
function promptComposerNeedle(prompt) {
|
|
@@ -239,8 +247,7 @@ export function injectPrompt(
|
|
|
239
247
|
prompt,
|
|
240
248
|
{ useFileRef = false, cli = null } = {},
|
|
241
249
|
) {
|
|
242
|
-
const tmpDir =
|
|
243
|
-
mkdirSync(tmpDir, { recursive: true });
|
|
250
|
+
const tmpDir = privateTmpDir("tfx-multi");
|
|
244
251
|
|
|
245
252
|
const safeTarget = target.replace(/[:.]/g, "-");
|
|
246
253
|
const tmpFile = join(tmpDir, `prompt-${safeTarget}-${Date.now()}.txt`);
|
|
@@ -294,10 +301,11 @@ export function injectPrompt(
|
|
|
294
301
|
|
|
295
302
|
// tmux load-buffer → paste-buffer → (정착 지연 + 제출 확인) Enter
|
|
296
303
|
waitForComposerReady(target);
|
|
297
|
-
muxExec(
|
|
298
|
-
|
|
304
|
+
muxExec(["load-buffer", toMuxPath(tmpFile)]);
|
|
305
|
+
// -p 가 없으면 줄바꿈이 CR(Enter)로 들어가 여러 줄 지시문이 줄마다 제출된다.
|
|
306
|
+
muxExec(["paste-buffer", "-p", "-t", target]);
|
|
299
307
|
confirmSubmit(target, prompt, cli, () =>
|
|
300
|
-
muxExec(
|
|
308
|
+
muxExec(["send-keys", "-t", target, "Enter"]),
|
|
301
309
|
);
|
|
302
310
|
} finally {
|
|
303
311
|
try {
|
|
@@ -312,5 +320,10 @@ export function injectPrompt(
|
|
|
312
320
|
* @param {string} keys — tmux 키 표현 (예: 'C-c', 'Enter')
|
|
313
321
|
*/
|
|
314
322
|
export function sendKeys(target, keys) {
|
|
315
|
-
muxExec(
|
|
323
|
+
muxExec([
|
|
324
|
+
"send-keys",
|
|
325
|
+
"-t",
|
|
326
|
+
target,
|
|
327
|
+
...String(keys).split(/\s+/u).filter(Boolean),
|
|
328
|
+
]);
|
|
316
329
|
}
|
package/hub/team/psmux.mjs
CHANGED
|
@@ -19,10 +19,11 @@ import {
|
|
|
19
19
|
readFileSync,
|
|
20
20
|
writeFileSync,
|
|
21
21
|
} from "node:fs";
|
|
22
|
-
import { homedir
|
|
22
|
+
import { homedir } from "node:os";
|
|
23
23
|
import { join } from "node:path";
|
|
24
24
|
import { formatPsmuxInstallGuidance } from "../../scripts/lib/psmux-info.mjs";
|
|
25
25
|
import { resolveGitBashExecutable } from "@triflux/core/hub/lib/bash-path.mjs";
|
|
26
|
+
import { privateTmpDir } from "@triflux/core/hub/lib/private-tmp.mjs";
|
|
26
27
|
import childProcess from "@triflux/core/hub/lib/spawn-trace.mjs";
|
|
27
28
|
import { IS_WINDOWS } from "@triflux/core/hub/platform.mjs";
|
|
28
29
|
|
|
@@ -92,12 +93,10 @@ const PWSH_BIN = (() => {
|
|
|
92
93
|
})();
|
|
93
94
|
const PSMUX_TIMEOUT_MS = 10000;
|
|
94
95
|
const COMPLETION_PREFIX = "__TRIFLUX_DONE__:";
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
IS_WINDOWS ? "pipe-pane-capture.ps1" : "pipe-pane-capture.sh",
|
|
100
|
-
);
|
|
96
|
+
// 실행되는 캡처 스크립트가 있어 남이 바꿀 수 없는 사용자별 0700 디렉터리에 둔다.
|
|
97
|
+
function captureRoot() {
|
|
98
|
+
return process.env.PSMUX_CAPTURE_ROOT || privateTmpDir("psmux-steering");
|
|
99
|
+
}
|
|
101
100
|
const POLL_INTERVAL_MS = (() => {
|
|
102
101
|
const ms = Number.parseInt(process.env.PSMUX_POLL_INTERVAL_MS || "", 10);
|
|
103
102
|
if (Number.isFinite(ms) && ms > 0) return ms;
|
|
@@ -137,7 +136,7 @@ function sleepMsAsync(ms) {
|
|
|
137
136
|
return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
|
|
138
137
|
}
|
|
139
138
|
|
|
140
|
-
function tokenizeCommand(command) {
|
|
139
|
+
export function tokenizeCommand(command) {
|
|
141
140
|
const source = String(command || "").trim();
|
|
142
141
|
if (!source) return [];
|
|
143
142
|
|
|
@@ -232,7 +231,7 @@ function ensurePsmuxInstalled() {
|
|
|
232
231
|
}
|
|
233
232
|
|
|
234
233
|
function getCaptureSessionDir(sessionName) {
|
|
235
|
-
return join(
|
|
234
|
+
return join(captureRoot(), sanitizePathPart(sessionName));
|
|
236
235
|
}
|
|
237
236
|
|
|
238
237
|
function getCaptureLogPath(sessionName, paneName) {
|
|
@@ -243,10 +242,15 @@ function getCaptureLogPath(sessionName, paneName) {
|
|
|
243
242
|
}
|
|
244
243
|
|
|
245
244
|
function ensureCaptureHelper() {
|
|
246
|
-
|
|
245
|
+
const root = captureRoot();
|
|
246
|
+
mkdirSync(root, { recursive: true });
|
|
247
|
+
const helperPath = join(
|
|
248
|
+
root,
|
|
249
|
+
IS_WINDOWS ? "pipe-pane-capture.ps1" : "pipe-pane-capture.sh",
|
|
250
|
+
);
|
|
247
251
|
if (IS_WINDOWS) {
|
|
248
252
|
writeFileSync(
|
|
249
|
-
|
|
253
|
+
helperPath,
|
|
250
254
|
[
|
|
251
255
|
"param(",
|
|
252
256
|
" [Parameter(Mandatory = $true)][string]$Path",
|
|
@@ -276,7 +280,7 @@ function ensureCaptureHelper() {
|
|
|
276
280
|
} else {
|
|
277
281
|
// macOS/Linux: bash 스크립트로 pipe-pane 캡처
|
|
278
282
|
writeFileSync(
|
|
279
|
-
|
|
283
|
+
helperPath,
|
|
280
284
|
[
|
|
281
285
|
"#!/bin/bash",
|
|
282
286
|
'mkdir -p "$(dirname "$1")" 2>/dev/null',
|
|
@@ -285,9 +289,9 @@ function ensureCaptureHelper() {
|
|
|
285
289
|
].join("\n"),
|
|
286
290
|
"utf8",
|
|
287
291
|
);
|
|
288
|
-
chmodSync(
|
|
292
|
+
chmodSync(helperPath, 0o755);
|
|
289
293
|
}
|
|
290
|
-
return
|
|
294
|
+
return helperPath;
|
|
291
295
|
}
|
|
292
296
|
|
|
293
297
|
function readCaptureLog(logPath) {
|
|
@@ -454,11 +458,17 @@ function parsePaneDetails(output) {
|
|
|
454
458
|
.filter((entry) => entry.paneId);
|
|
455
459
|
}
|
|
456
460
|
|
|
461
|
+
// tmux 는 -t 세션 이름을 접두사로도 매칭해, 대상이 이미 없으면 이름이 이어지는
|
|
462
|
+
// 다른 세션을 잡는다(#548). psmux 는 정확히만 매칭하고 3.3.8 전에는 '=' 를 모른다.
|
|
463
|
+
function exactSessionTarget(sessionName, windowSuffix = "") {
|
|
464
|
+
return `${IS_WINDOWS ? "" : "="}${sessionName}${windowSuffix}`;
|
|
465
|
+
}
|
|
466
|
+
|
|
457
467
|
function collectSessionPanes(sessionName) {
|
|
458
468
|
const output = psmuxExec([
|
|
459
469
|
"list-panes",
|
|
460
470
|
"-t",
|
|
461
|
-
|
|
471
|
+
exactSessionTarget(sessionName, ":0"),
|
|
462
472
|
"-F",
|
|
463
473
|
"#{pane_index}\t#{session_name}:#{window_index}.#{pane_index}",
|
|
464
474
|
]);
|
|
@@ -762,7 +772,7 @@ function collectPanePids(sessionName) {
|
|
|
762
772
|
const output = psmuxExec([
|
|
763
773
|
"list-panes",
|
|
764
774
|
"-t",
|
|
765
|
-
sessionName,
|
|
775
|
+
exactSessionTarget(sessionName, ":"),
|
|
766
776
|
"-F",
|
|
767
777
|
"#{pane_pid}",
|
|
768
778
|
]);
|
|
@@ -872,7 +882,7 @@ function disableAllPipeCaptures(sessionName, paneIds) {
|
|
|
872
882
|
function killOrphanPipeHelpers(sessionName) {
|
|
873
883
|
if (!IS_WINDOWS) {
|
|
874
884
|
// macOS/Linux: pipe-pane helper cmdline contains
|
|
875
|
-
// `<
|
|
885
|
+
// `<captureRoot()>/<session>/<pane>.log`, so anchor on `/<session>/`.
|
|
876
886
|
const safeSessionUnix = escapeRegex(sanitizePathPart(sessionName));
|
|
877
887
|
try {
|
|
878
888
|
const pids = childProcess
|
|
@@ -1025,7 +1035,9 @@ export function killPsmuxSession(sessionName) {
|
|
|
1025
1035
|
|
|
1026
1036
|
// 3. psmux 세션 자체 종료
|
|
1027
1037
|
try {
|
|
1028
|
-
psmuxExec(["kill-session", "-t", sessionName], {
|
|
1038
|
+
psmuxExec(["kill-session", "-t", exactSessionTarget(sessionName)], {
|
|
1039
|
+
stdio: "ignore",
|
|
1040
|
+
});
|
|
1029
1041
|
} catch {
|
|
1030
1042
|
// 이미 종료된 세션 — 무시
|
|
1031
1043
|
}
|
|
@@ -1064,7 +1076,9 @@ export function killSessionByTitle(titlePattern) {
|
|
|
1064
1076
|
const sessions = listSessions({ filterTitle: titlePattern });
|
|
1065
1077
|
const killed = [];
|
|
1066
1078
|
for (const session of sessions) {
|
|
1067
|
-
psmuxExec(["kill-session", "-t", session.sessionName], {
|
|
1079
|
+
psmuxExec(["kill-session", "-t", exactSessionTarget(session.sessionName)], {
|
|
1080
|
+
stdio: "ignore",
|
|
1081
|
+
});
|
|
1068
1082
|
killed.push(session.sessionName);
|
|
1069
1083
|
}
|
|
1070
1084
|
return {
|
|
@@ -1130,7 +1144,9 @@ export function pruneStale(opts = {}) {
|
|
|
1130
1144
|
*/
|
|
1131
1145
|
export function psmuxSessionExists(sessionName) {
|
|
1132
1146
|
try {
|
|
1133
|
-
psmuxExec(["has-session", "-t", sessionName], {
|
|
1147
|
+
psmuxExec(["has-session", "-t", exactSessionTarget(sessionName)], {
|
|
1148
|
+
stdio: "ignore",
|
|
1149
|
+
});
|
|
1134
1150
|
return true;
|
|
1135
1151
|
} catch {
|
|
1136
1152
|
return false;
|