@triflux/remote 10.43.0 → 10.45.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/cto/hygiene.mjs +1 -1
- package/hub/lib/cto-env.mjs +1 -0
- package/hub/pipe.mjs +10 -9
- package/hub/schema.sql +62 -2
- package/hub/server.mjs +276 -132
- package/hub/store.mjs +699 -41
- package/hub/team/backend.mjs +4 -2
- package/hub/team/claude-daemon-control.mjs +2 -0
- package/hub/team/cli/commands/start/start-headless.mjs +2 -0
- package/hub/team/cli/commands/start/start-mux.mjs +24 -3
- package/hub/team/cli/services/runtime-mode.mjs +23 -9
- package/hub/team/conductor.mjs +101 -24
- package/hub/team/cto-auto-collect.mjs +2 -6
- package/hub/team/cto-pull.mjs +4 -0
- package/hub/team/dashboard-open.mjs +1 -0
- package/hub/team/execution-mode.mjs +50 -9
- package/hub/team/headless.mjs +539 -92
- package/hub/team/interactive-tui-transport.mjs +15 -1
- package/hub/team/intervention.mjs +1 -1
- package/hub/team/launcher-template.mjs +17 -1
- package/hub/team/notify.mjs +31 -11
- package/hub/team/orchestrator.mjs +36 -9
- package/hub/team/pane.mjs +176 -14
- package/hub/team/psmux.mjs +79 -70
- package/hub/team/retry-state-machine.mjs +41 -13
- package/hub/team/session.mjs +57 -0
- package/hub/team/swarm-hypervisor.mjs +4 -2
- package/hub/team/swarm-preflight.mjs +47 -0
- package/hub/team/synapse-http.mjs +149 -4
- package/hub/team/terminal-opener.mjs +68 -13
- package/hub/team/tui-viewer.mjs +65 -14
- package/hub/team/worktree-lifecycle.mjs +56 -59
- package/hub/tools.mjs +22 -1
- package/hub/workers/codex-mcp.mjs +26 -5
- package/hub/workers/delegator-mcp.mjs +3 -49
- package/package.json +1 -1
- package/scripts/lib/agent-route-policy.mjs +367 -0
- package/scripts/lib/cli-agy.mjs +24 -4
- package/scripts/lib/cli-codex.mjs +22 -160
- package/scripts/lib/codex-profile-config.mjs +48 -4
- package/scripts/lib/codex-recovery.sh +19 -0
- package/scripts/lib/gemini-profiles.mjs +113 -29
- package/scripts/lib/keyword-rules.mjs +24 -0
- package/scripts/lib/logger.mjs +60 -43
- package/scripts/lib/machine-profile.mjs +293 -0
- package/scripts/lib/mcp-health.mjs +44 -18
- package/cto/steward.mjs +0 -362
- package/hub/log-retention.mjs +0 -726
|
@@ -23,6 +23,8 @@ import {
|
|
|
23
23
|
} from "node:fs";
|
|
24
24
|
import { dirname, join } from "node:path";
|
|
25
25
|
|
|
26
|
+
import { normalizeCodexProfileName } from "../../scripts/lib/codex-profile-config.mjs";
|
|
27
|
+
|
|
26
28
|
export const STATES = Object.freeze({
|
|
27
29
|
PLANNING: "PLANNING",
|
|
28
30
|
EXECUTING: "EXECUTING",
|
|
@@ -39,21 +41,29 @@ export const MODES = Object.freeze({
|
|
|
39
41
|
});
|
|
40
42
|
|
|
41
43
|
const ESCALATION_CHAIN_CONFIG_PATH = ".triflux/config/escalation-chain.json";
|
|
44
|
+
const DEFAULT_CLAUDE_MODEL = "fable";
|
|
42
45
|
|
|
43
|
-
// Escalation chain (2026-
|
|
44
|
-
// 1. codex gpt-
|
|
45
|
-
// 2. claude
|
|
46
|
+
// Escalation chain (2026-09-22 정책):
|
|
47
|
+
// 1. codex gpt-6-astra max: 최난도 단일 작업용 Codex escalation 단계
|
|
48
|
+
// 2. claude fable alias: Claude CLI가 최신 Fable tier로 해석하는 최종 수단
|
|
46
49
|
const DEFAULT_ESCALATION_CHAIN = Object.freeze([
|
|
47
50
|
Object.freeze({
|
|
48
51
|
cli: "codex",
|
|
49
|
-
model: "gpt-
|
|
50
|
-
profile: "
|
|
52
|
+
model: "gpt-6-astra",
|
|
53
|
+
profile: "gpt6_astra_max",
|
|
51
54
|
}),
|
|
52
|
-
Object.freeze({ cli: "claude", model:
|
|
55
|
+
Object.freeze({ cli: "claude", model: DEFAULT_CLAUDE_MODEL }),
|
|
53
56
|
]);
|
|
54
57
|
|
|
55
58
|
const STUCK_THRESHOLD = 3;
|
|
56
59
|
|
|
60
|
+
function normalizeEscalationProfile(profile) {
|
|
61
|
+
const normalized = normalizeCodexProfileName(profile);
|
|
62
|
+
return normalized === "ultra" || normalized === "gpt6_astra_ultra"
|
|
63
|
+
? "gpt6_astra_max"
|
|
64
|
+
: normalized;
|
|
65
|
+
}
|
|
66
|
+
|
|
57
67
|
export function createRetryStateMachine(options = {}) {
|
|
58
68
|
const mode = options.mode || MODES.BOUNDED;
|
|
59
69
|
const cliChain = resolveEscalationChain(options);
|
|
@@ -234,15 +244,25 @@ function resolveEscalationChain(options) {
|
|
|
234
244
|
}
|
|
235
245
|
|
|
236
246
|
const projectRoot = options.projectRoot || process.cwd();
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
247
|
+
const projectOverride = loadEscalationChainOverride(projectRoot);
|
|
248
|
+
if (projectOverride) return projectOverride;
|
|
249
|
+
|
|
250
|
+
return normalizeEscalationChain(
|
|
251
|
+
resolveDefaultEscalationChain(options.env || process.env),
|
|
252
|
+
"DEFAULT_ESCALATION_CHAIN",
|
|
243
253
|
);
|
|
244
254
|
}
|
|
245
255
|
|
|
256
|
+
function resolveDefaultEscalationChain(env) {
|
|
257
|
+
const configuredModel = env?.TFX_ESCALATION_CLAUDE_MODEL?.trim();
|
|
258
|
+
if (!configuredModel) return DEFAULT_ESCALATION_CHAIN;
|
|
259
|
+
|
|
260
|
+
return [
|
|
261
|
+
DEFAULT_ESCALATION_CHAIN[0],
|
|
262
|
+
{ cli: "claude", model: configuredModel },
|
|
263
|
+
];
|
|
264
|
+
}
|
|
265
|
+
|
|
246
266
|
export function loadEscalationChainOverride(projectRoot = process.cwd()) {
|
|
247
267
|
const configPath = join(projectRoot, ESCALATION_CHAIN_CONFIG_PATH);
|
|
248
268
|
if (!existsSync(configPath)) return null;
|
|
@@ -295,7 +315,9 @@ function normalizeEscalationChainEntry(entry, index, source) {
|
|
|
295
315
|
model: entry.model,
|
|
296
316
|
};
|
|
297
317
|
if (typeof entry.profile === "string" && entry.profile.trim() !== "") {
|
|
298
|
-
|
|
318
|
+
const profile = entry.profile.trim();
|
|
319
|
+
normalized.profile =
|
|
320
|
+
entry.cli === "codex" ? normalizeEscalationProfile(profile) : profile;
|
|
299
321
|
}
|
|
300
322
|
return normalized;
|
|
301
323
|
}
|
|
@@ -328,6 +350,12 @@ export function loadSnapshot(snapshotFile) {
|
|
|
328
350
|
`unsupported snapshot version: ${parsed.version} (expected 1)`,
|
|
329
351
|
);
|
|
330
352
|
}
|
|
353
|
+
if (Array.isArray(parsed.cliChain) && parsed.cliChain.length > 0) {
|
|
354
|
+
return {
|
|
355
|
+
...parsed,
|
|
356
|
+
cliChain: normalizeEscalationChain(parsed.cliChain, "snapshot.cliChain"),
|
|
357
|
+
};
|
|
358
|
+
}
|
|
331
359
|
return parsed;
|
|
332
360
|
}
|
|
333
361
|
|
package/hub/team/session.mjs
CHANGED
|
@@ -475,6 +475,63 @@ export function attachSession(sessionName) {
|
|
|
475
475
|
}
|
|
476
476
|
}
|
|
477
477
|
|
|
478
|
+
/**
|
|
479
|
+
* non-TTY 환경용 attach fallback: 요청 프로세스가 명시적으로 넘긴 lead pane을
|
|
480
|
+
* 분할해 그 pane 안에서 대상 세션에 attach한다.
|
|
481
|
+
*
|
|
482
|
+
* 호출 프로세스가 pipe(non-TTY)로 실행돼 attachSession()이 불가능해도,
|
|
483
|
+
* tmux 서버에 보내는 split-window는 호출자 TTY가 필요 없다. 새 pane에는
|
|
484
|
+
* tmux가 실제 pty를 붙여주므로 그 안의 attach는 정상 동작한다.
|
|
485
|
+
* env -u TMUX는 nested-attach 보호 해제용이다.
|
|
486
|
+
*
|
|
487
|
+
* @param {string} sessionName attach할 대상 세션
|
|
488
|
+
* @param {string} targetPane 요청 lead의 `$TMUX_PANE` (`%N`)
|
|
489
|
+
* @param {string} tmuxEnv 요청 lead의 `$TMUX` (`socket,pid,index`)
|
|
490
|
+
* @returns {boolean} 분할 attach 성공 여부 (false면 호출자가 기존 생략 경로 유지)
|
|
491
|
+
*/
|
|
492
|
+
export function buildSplitAttachArgs(sessionName, targetPane, tmuxEnv) {
|
|
493
|
+
const normalizedSessionName = String(sessionName || "");
|
|
494
|
+
if (!normalizedSessionName.trim()) {
|
|
495
|
+
throw new Error("session name is required");
|
|
496
|
+
}
|
|
497
|
+
if (!/^%\d+$/u.test(String(targetPane || ""))) {
|
|
498
|
+
throw new Error("requesting lead TMUX_PANE is required");
|
|
499
|
+
}
|
|
500
|
+
const socketPath = String(tmuxEnv || "").split(",", 1)[0];
|
|
501
|
+
if (!socketPath.trim()) {
|
|
502
|
+
throw new Error("requesting lead TMUX socket is required");
|
|
503
|
+
}
|
|
504
|
+
const quotedSocket = `'${socketPath.replace(/'/gu, "'\\''")}'`;
|
|
505
|
+
const quotedSessionName = `'${normalizedSessionName.replace(/'/gu, "'\\''")}'`;
|
|
506
|
+
return [
|
|
507
|
+
"split-window",
|
|
508
|
+
"-t",
|
|
509
|
+
targetPane,
|
|
510
|
+
"-v",
|
|
511
|
+
"-l",
|
|
512
|
+
"50%",
|
|
513
|
+
`env -u TMUX tmux -S ${quotedSocket} attach -t ${quotedSessionName}`,
|
|
514
|
+
];
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export function splitAttachToClient(sessionName, opts = {}) {
|
|
518
|
+
const deps = opts._deps || {};
|
|
519
|
+
const detectMux = deps.detectMultiplexer || detectMultiplexer;
|
|
520
|
+
if (detectMux() !== "tmux") return false;
|
|
521
|
+
|
|
522
|
+
let args;
|
|
523
|
+
try {
|
|
524
|
+
args = buildSplitAttachArgs(sessionName, opts.targetPane, opts.tmuxEnv);
|
|
525
|
+
} catch {
|
|
526
|
+
return false;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// 상하 분할: 좌우 분할은 절반 폭이 워커 pane 가독 하한(100cols) 미만인 경우가 흔함
|
|
530
|
+
const spawn = deps.spawnSync || spawnSync;
|
|
531
|
+
const split = spawn("tmux", args);
|
|
532
|
+
return (split.status ?? 1) === 0;
|
|
533
|
+
}
|
|
534
|
+
|
|
478
535
|
/**
|
|
479
536
|
* tmux 세션 존재 확인
|
|
480
537
|
* @param {string} sessionName
|
|
@@ -20,8 +20,8 @@ import {
|
|
|
20
20
|
isDynamicRoutingEnabled,
|
|
21
21
|
} from "@triflux/core/hub/dynamic-routing-engine.mjs";
|
|
22
22
|
import { cleanupShardProcesses } from "@triflux/core/hub/lib/process-utils.mjs";
|
|
23
|
-
import { DEFAULT_SWARM_LOCK_TTL_MS } from "@triflux/core/hub/lib/timeout-defaults.mjs";
|
|
24
23
|
import { getHostConfig } from "@triflux/core/hub/lib/ssh-command.mjs";
|
|
24
|
+
import { DEFAULT_SWARM_LOCK_TTL_MS } from "@triflux/core/hub/lib/timeout-defaults.mjs";
|
|
25
25
|
import { buildWorkerPrompt } from "./build-worker-prompt.mjs";
|
|
26
26
|
import { registerSwarmShard } from "./claude-native-bridge.mjs";
|
|
27
27
|
import { createConductor, STATES } from "./conductor.mjs";
|
|
@@ -678,6 +678,7 @@ export function createSwarmHypervisor(opts) {
|
|
|
678
678
|
const config = {
|
|
679
679
|
id: buildSwarmSessionId(shard),
|
|
680
680
|
agent: shard.agent,
|
|
681
|
+
role: shard.role,
|
|
681
682
|
// #125: append Completion Protocol appendix so workers emit a
|
|
682
683
|
// sentinel-framed JSON payload that conductor can reliably capture.
|
|
683
684
|
prompt: buildWorkerPrompt(shard.prompt, {
|
|
@@ -1964,7 +1965,8 @@ export function createSwarmHypervisor(opts) {
|
|
|
1964
1965
|
lockRenewTimer = setInterval(() => {
|
|
1965
1966
|
if (!lockManager) return;
|
|
1966
1967
|
for (const shardName of workers.keys()) {
|
|
1967
|
-
if (!completedShards.has(shardName) && !failures.has(shardName))
|
|
1968
|
+
if (!completedShards.has(shardName) && !failures.has(shardName))
|
|
1969
|
+
lockManager.renew(shardName);
|
|
1968
1970
|
}
|
|
1969
1971
|
}, DEFAULT_SWARM_LOCK_TTL_MS / 2);
|
|
1970
1972
|
lockRenewTimer.unref?.();
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// hub/team/swarm-preflight.mjs — PRD swarm preflight report/gate (#188)
|
|
2
2
|
// Predicts launch-time blockers before any worker session or worktree spawn.
|
|
3
3
|
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
4
5
|
import { resolve } from "node:path";
|
|
5
6
|
import { readHosts, resolveHost } from "@triflux/core/hub/lib/hosts-compat.mjs";
|
|
6
7
|
import { whichCommand } from "@triflux/core/hub/platform.mjs";
|
|
@@ -83,6 +84,44 @@ function checkSensitive(plan, sensitiveDeny) {
|
|
|
83
84
|
return makeCheck("sensitiveDeny", true, { matches });
|
|
84
85
|
}
|
|
85
86
|
|
|
87
|
+
function parseTrackedStatus(rawStatus) {
|
|
88
|
+
const files = [];
|
|
89
|
+
const records = String(rawStatus || "").split("\0");
|
|
90
|
+
for (let index = 0; index < records.length; index++) {
|
|
91
|
+
const record = records[index];
|
|
92
|
+
if (record.length < 4) continue;
|
|
93
|
+
const status = record.slice(0, 2);
|
|
94
|
+
files.push(normalizePath(record.slice(3)));
|
|
95
|
+
if (/[RC]/u.test(status) && records[index + 1]) {
|
|
96
|
+
files.push(normalizePath(records[++index]));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return unique(files);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function checkRootDirtyLease(plan, repoRoot, deps = {}) {
|
|
103
|
+
const gitStatus =
|
|
104
|
+
deps.gitStatus ||
|
|
105
|
+
((cwd) =>
|
|
106
|
+
execFileSync(
|
|
107
|
+
"git",
|
|
108
|
+
["status", "--porcelain=v1", "-z", "--untracked-files=no"],
|
|
109
|
+
{ cwd, encoding: "utf8", windowsHide: true },
|
|
110
|
+
));
|
|
111
|
+
const dirtyFiles = parseTrackedStatus(gitStatus(repoRoot));
|
|
112
|
+
const leasedFiles = unique(
|
|
113
|
+
[...plan.leaseMap.values()].flat().map(normalizePath),
|
|
114
|
+
);
|
|
115
|
+
const leaseSet = new Set(leasedFiles);
|
|
116
|
+
const overlappingFiles = dirtyFiles.filter((file) => leaseSet.has(file));
|
|
117
|
+
|
|
118
|
+
return makeCheck("rootDirtyLease", overlappingFiles.length === 0, {
|
|
119
|
+
dirtyFiles,
|
|
120
|
+
leasedFiles,
|
|
121
|
+
overlappingFiles,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
86
125
|
function checkHosts(plan, repoRoot) {
|
|
87
126
|
const registry = readHosts(repoRoot);
|
|
88
127
|
const missing = [];
|
|
@@ -216,6 +255,13 @@ function collectMessages(checks) {
|
|
|
216
255
|
);
|
|
217
256
|
}
|
|
218
257
|
|
|
258
|
+
const rootDirtyLease = checks.rootDirtyLease;
|
|
259
|
+
if (rootDirtyLease && !rootDirtyLease.ok) {
|
|
260
|
+
errors.push(
|
|
261
|
+
`rootDir tracked changes overlap shard leases: ${rootDirtyLease.overlappingFiles.join(", ")}`,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
219
265
|
return { errors: unique(errors), warnings: unique(warnings) };
|
|
220
266
|
}
|
|
221
267
|
|
|
@@ -258,6 +304,7 @@ export function runSwarmPreflight(prdPath, opts = {}) {
|
|
|
258
304
|
leases: makeCheck("leases", plan.conflicts.length === 0, {
|
|
259
305
|
conflicts: [...plan.conflicts],
|
|
260
306
|
}),
|
|
307
|
+
rootDirtyLease: checkRootDirtyLease(plan, repoRoot, opts.deps),
|
|
261
308
|
sensitiveDeny: checkSensitive(plan, sensitiveDeny),
|
|
262
309
|
hosts: checkHosts(plan, repoRoot),
|
|
263
310
|
workerCli: checkLocalWorkerClis(plan, opts.deps),
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { homedir, hostname } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { normalizeRoleKey } from "@triflux/core/hub/role-contract.mjs";
|
|
4
7
|
|
|
5
8
|
const HUB_DEFAULT_PORT = 27888;
|
|
6
9
|
// Same token file as hub/bridge.mjs (HUB_TOKEN_FILE). Kept inline rather than
|
|
@@ -21,6 +24,144 @@ function normalizeToken(raw) {
|
|
|
21
24
|
return token || null;
|
|
22
25
|
}
|
|
23
26
|
|
|
27
|
+
function opaqueId(prefix, value) {
|
|
28
|
+
return `${prefix}_${createHash("sha256")
|
|
29
|
+
.update(String(value || ""))
|
|
30
|
+
.digest("base64url")
|
|
31
|
+
.slice(0, 22)}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizedProjectId(value) {
|
|
35
|
+
try {
|
|
36
|
+
return normalizeRoleKey({ project_id: value, role_kind: "cto" }).project_id;
|
|
37
|
+
} catch {
|
|
38
|
+
return "";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function resolveProjectId(cwd, meta, opts = {}) {
|
|
43
|
+
const explicit = meta?.project_id || process.env.TFX_PROJECT_ID;
|
|
44
|
+
if (explicit) return normalizedProjectId(explicit);
|
|
45
|
+
if (typeof opts.resolveProjectId === "function") {
|
|
46
|
+
return normalizedProjectId(opts.resolveProjectId(cwd));
|
|
47
|
+
}
|
|
48
|
+
let current = cwd;
|
|
49
|
+
while (current) {
|
|
50
|
+
const manifest = join(current, ".triflux", "project.json");
|
|
51
|
+
if (existsSync(manifest)) {
|
|
52
|
+
try {
|
|
53
|
+
const parsed = JSON.parse(readFileSync(manifest, "utf8"));
|
|
54
|
+
if (parsed?.schema_version !== "tfx.project.v1") return "";
|
|
55
|
+
return normalizedProjectId(parsed.project_id);
|
|
56
|
+
} catch {
|
|
57
|
+
return "";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const parent = dirname(current);
|
|
61
|
+
if (parent === current) break;
|
|
62
|
+
current = parent;
|
|
63
|
+
}
|
|
64
|
+
return "";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function inferSessionCli(meta, opts = {}) {
|
|
68
|
+
const explicit = String(meta?.cli || meta?.actor_cli || "")
|
|
69
|
+
.trim()
|
|
70
|
+
.toLowerCase();
|
|
71
|
+
if (explicit === "codex" || explicit === "claude") return explicit;
|
|
72
|
+
const entrypoint = String(opts.entrypoint ?? process.argv[1] ?? "");
|
|
73
|
+
if (entrypoint.includes("codex-session-hook")) return "codex";
|
|
74
|
+
if (entrypoint.includes("agy-session-hook")) return "other";
|
|
75
|
+
return "claude";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function resolveTmuxSession(meta, opts = {}) {
|
|
79
|
+
const explicit =
|
|
80
|
+
meta?.tmux_session || meta?.session_name || process.env.TFX_TMUX_SESSION;
|
|
81
|
+
if (explicit) return String(explicit).trim();
|
|
82
|
+
if (!process.env.TMUX) return "";
|
|
83
|
+
try {
|
|
84
|
+
const run =
|
|
85
|
+
opts.tmuxSessionName ||
|
|
86
|
+
(() =>
|
|
87
|
+
execFileSync("tmux", ["display-message", "-p", "#{session_name}"], {
|
|
88
|
+
encoding: "utf8",
|
|
89
|
+
timeout: 200,
|
|
90
|
+
windowsHide: true,
|
|
91
|
+
}));
|
|
92
|
+
return String(run() || "").trim();
|
|
93
|
+
} catch {
|
|
94
|
+
return "";
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function buildSynapseRegistrationMeta(meta, opts = {}) {
|
|
99
|
+
if (!meta || meta.sessionKind !== "interactive") return meta;
|
|
100
|
+
const sessionId = String(meta.sessionId || "").trim();
|
|
101
|
+
if (!sessionId) return meta;
|
|
102
|
+
const cwd = typeof meta.cwd === "string" ? meta.cwd : process.cwd();
|
|
103
|
+
const cli = inferSessionCli(meta, opts);
|
|
104
|
+
const hostId = String(
|
|
105
|
+
meta.host_id ||
|
|
106
|
+
process.env.TFX_HOST_ID ||
|
|
107
|
+
opaqueId("hst", opts.hostname?.() || hostname()),
|
|
108
|
+
).trim();
|
|
109
|
+
const expiresAtMs = Number(opts.nowMs?.() ?? Date.now()) + 299000;
|
|
110
|
+
const transportLocators = [];
|
|
111
|
+
if (cli === "claude") {
|
|
112
|
+
transportLocators.push({
|
|
113
|
+
schema_version: "tfx.transport-locator.v1",
|
|
114
|
+
transport_id: opaqueId("trn", `claude-uds:${hostId}:${sessionId}`),
|
|
115
|
+
kind: "claude-uds",
|
|
116
|
+
host_id: hostId,
|
|
117
|
+
capabilities: ["probe", "wake", "activate", "interrupt"],
|
|
118
|
+
priority: 100,
|
|
119
|
+
expires_at_ms: expiresAtMs,
|
|
120
|
+
locator: {
|
|
121
|
+
session_id: sessionId,
|
|
122
|
+
bridge_id: String(
|
|
123
|
+
meta.bridge_id || process.env.TFX_BRIDGE_ID || "local-hub",
|
|
124
|
+
),
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
const tmuxSession = resolveTmuxSession(meta, opts);
|
|
129
|
+
if (tmuxSession) {
|
|
130
|
+
const muxServerSource =
|
|
131
|
+
meta.mux_server_id ||
|
|
132
|
+
process.env.TFX_MUX_SERVER_ID ||
|
|
133
|
+
String(process.env.TMUX || "default").split(",", 1)[0];
|
|
134
|
+
transportLocators.push({
|
|
135
|
+
schema_version: "tfx.transport-locator.v1",
|
|
136
|
+
transport_id: opaqueId("trn", `tmux:${hostId}:${tmuxSession}`),
|
|
137
|
+
kind: "tmux",
|
|
138
|
+
host_id: hostId,
|
|
139
|
+
capabilities: ["probe", "wake", "activate", "interrupt"],
|
|
140
|
+
priority: cli === "codex" ? 100 : 50,
|
|
141
|
+
expires_at_ms: expiresAtMs,
|
|
142
|
+
locator: {
|
|
143
|
+
session_name: tmuxSession,
|
|
144
|
+
mux_server_id: opaqueId("mux", muxServerSource),
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
const directAgentId = `session:${sessionId}`;
|
|
149
|
+
return {
|
|
150
|
+
...meta,
|
|
151
|
+
agent_id:
|
|
152
|
+
meta.agent_id ||
|
|
153
|
+
(directAgentId.length <= 64 && /^[a-zA-Z0-9._:-]+$/u.test(directAgentId)
|
|
154
|
+
? directAgentId
|
|
155
|
+
: opaqueId("session", sessionId)),
|
|
156
|
+
cli,
|
|
157
|
+
project_id: resolveProjectId(cwd, meta, opts) || undefined,
|
|
158
|
+
session_id: meta.session_id || sessionId,
|
|
159
|
+
host_id: hostId,
|
|
160
|
+
transport_locators_json:
|
|
161
|
+
meta.transport_locators_json ?? JSON.stringify(transportLocators),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
24
165
|
// Mirrors hub/bridge.mjs:readHubToken — env override first, then the token file.
|
|
25
166
|
// Missing token → null (backward-compatible with token-less hubs).
|
|
26
167
|
function readHubToken() {
|
|
@@ -139,7 +280,11 @@ export function drainPendingSynapse(timeoutMs = 1000) {
|
|
|
139
280
|
}
|
|
140
281
|
|
|
141
282
|
export function registerSynapseSession(meta, opts = {}) {
|
|
142
|
-
return fireAndForgetSynapse(
|
|
283
|
+
return fireAndForgetSynapse(
|
|
284
|
+
"/synapse/register",
|
|
285
|
+
buildSynapseRegistrationMeta(meta, opts),
|
|
286
|
+
opts,
|
|
287
|
+
);
|
|
143
288
|
}
|
|
144
289
|
|
|
145
290
|
export function heartbeatSynapseSession(
|
|
@@ -111,6 +111,35 @@ function wtResultSucceeded(result) {
|
|
|
111
111
|
return result?.success !== false;
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
function resolveLeadPane(value) {
|
|
115
|
+
const pane = String(value || "");
|
|
116
|
+
return /^%\d+$/u.test(pane) ? pane : null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function resolveLeadWindow(tmuxExec, targetPane) {
|
|
120
|
+
try {
|
|
121
|
+
const targetWindow = String(
|
|
122
|
+
tmuxExec(
|
|
123
|
+
`display-message -p -t ${targetPane} '#{session_name}:#{window_index}'`,
|
|
124
|
+
) ?? "",
|
|
125
|
+
).trim();
|
|
126
|
+
return targetWindow || null;
|
|
127
|
+
} catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function resolveLeadSocket(tmuxExec, targetPane) {
|
|
133
|
+
try {
|
|
134
|
+
const socketPath = String(
|
|
135
|
+
tmuxExec(`display-message -p -t ${targetPane} '#{socket_path}'`) ?? "",
|
|
136
|
+
).trim();
|
|
137
|
+
return socketPath || null;
|
|
138
|
+
} catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
114
143
|
export function createTerminalOpener(deps = {}) {
|
|
115
144
|
const platform = resolvePlatform(deps);
|
|
116
145
|
const tmuxExec = deps.tmuxExec || defaultTmuxExec;
|
|
@@ -134,12 +163,22 @@ export function createTerminalOpener(deps = {}) {
|
|
|
134
163
|
|
|
135
164
|
const mux = resolveMux(deps);
|
|
136
165
|
if (isTmuxLikeMux(mux, platform)) {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
166
|
+
const targetPane = resolveLeadPane(spec.targetPane);
|
|
167
|
+
if (!targetPane) return false;
|
|
168
|
+
const targetWindow = resolveLeadWindow(tmuxExec, targetPane);
|
|
169
|
+
if (!targetWindow) return false;
|
|
170
|
+
try {
|
|
171
|
+
// tmux new-window는 pane target(%N)을 거부하므로, 요청 pane에서 해석한
|
|
172
|
+
// 정확한 session:window 뒤에 새 window를 삽입한다.
|
|
173
|
+
tmuxExec(
|
|
174
|
+
`new-window -a -t ${shellQuote(targetWindow)} -n ${shellQuote(
|
|
175
|
+
title,
|
|
176
|
+
)} ${shellQuote(buildCommandString(spec))}`,
|
|
177
|
+
);
|
|
178
|
+
return true;
|
|
179
|
+
} catch {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
143
182
|
}
|
|
144
183
|
|
|
145
184
|
if (platform === "darwin") {
|
|
@@ -174,14 +213,30 @@ export function createTerminalOpener(deps = {}) {
|
|
|
174
213
|
|
|
175
214
|
const mux = resolveMux(deps);
|
|
176
215
|
if (isTmuxLikeMux(mux, platform)) {
|
|
177
|
-
const
|
|
216
|
+
const targetPane = resolveLeadPane(opts.targetPane);
|
|
217
|
+
if (!targetPane) return false;
|
|
218
|
+
const socketPath =
|
|
219
|
+
mux === "psmux" ? null : resolveLeadSocket(tmuxExec, targetPane);
|
|
220
|
+
if (mux !== "psmux" && !socketPath) return false;
|
|
221
|
+
const attachCommand =
|
|
222
|
+
mux === "psmux"
|
|
223
|
+
? buildAttachCommand(mux, sessionName, deps)
|
|
224
|
+
: `env -u TMUX tmux -S ${shellQuote(
|
|
225
|
+
socketPath,
|
|
226
|
+
)} attach-session -t ${shellQuote(sessionName)}`;
|
|
178
227
|
if (!attachCommand) return false;
|
|
179
|
-
tmuxExec
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
228
|
+
const targetWindow = resolveLeadWindow(tmuxExec, targetPane);
|
|
229
|
+
if (!targetWindow) return false;
|
|
230
|
+
try {
|
|
231
|
+
tmuxExec(
|
|
232
|
+
`new-window -a -t ${shellQuote(targetWindow)} -n ${shellQuote(
|
|
233
|
+
opts.title ?? sessionName,
|
|
234
|
+
)} ${shellQuote(attachCommand)}`,
|
|
235
|
+
);
|
|
236
|
+
return true;
|
|
237
|
+
} catch {
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
185
240
|
}
|
|
186
241
|
|
|
187
242
|
return false;
|
package/hub/team/tui-viewer.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { join } from "node:path";
|
|
|
11
11
|
import { formatPsmuxInstallGuidance } from "../../scripts/lib/psmux-info.mjs";
|
|
12
12
|
import { openHeadlessDashboardTarget } from "./dashboard-open.mjs";
|
|
13
13
|
import { processHandoff } from "./handoff.mjs";
|
|
14
|
+
import { getMultiplexerType } from "./psmux.mjs";
|
|
14
15
|
import { createLogDashboard } from "./tui.mjs";
|
|
15
16
|
import { createLiteDashboard } from "./tui-lite.mjs";
|
|
16
17
|
|
|
@@ -24,23 +25,36 @@ function argVal(flag) {
|
|
|
24
25
|
const SESSION = argVal("--session");
|
|
25
26
|
const RESULT_DIR = argVal("--result-dir") ?? join(tmpdir(), "tfx-headless");
|
|
26
27
|
const LAYOUT = argVal("--layout") ?? "single";
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
const SOURCE = argVal("--source") === "files" ? "files" : "mux";
|
|
29
|
+
const LEAD_PANE = argVal("--lead-pane");
|
|
30
|
+
const parsedWorkerCount = Number.parseInt(argVal("--workers") || "0", 10);
|
|
31
|
+
const WORKER_COUNT = Number.isFinite(parsedWorkerCount)
|
|
32
|
+
? Math.min(64, Math.max(0, parsedWorkerCount))
|
|
33
|
+
: 0;
|
|
34
|
+
const MUX_BIN = getMultiplexerType();
|
|
35
|
+
|
|
36
|
+
if (!SESSION || !/^[a-zA-Z0-9_-]+$/u.test(SESSION)) {
|
|
29
37
|
process.stderr.write(
|
|
30
38
|
"Usage: node tui-viewer.mjs --session <name> [--result-dir <dir>] [--layout <name>]\n",
|
|
31
39
|
);
|
|
32
40
|
process.exit(1);
|
|
33
41
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
execFileSync("psmux", ["--version"], { encoding: "utf8", timeout: 2000 });
|
|
37
|
-
} catch {
|
|
38
|
-
process.stderr.write(
|
|
39
|
-
`ERROR: psmux 미설치. 설치 방법:\n${formatPsmuxInstallGuidance(" ")}\n`,
|
|
40
|
-
);
|
|
42
|
+
if (SOURCE === "files" && WORKER_COUNT === 0) {
|
|
43
|
+
process.stderr.write("ERROR: --source files requires --workers <count>\n");
|
|
41
44
|
process.exit(1);
|
|
42
45
|
}
|
|
43
46
|
|
|
47
|
+
if (SOURCE === "mux") {
|
|
48
|
+
try {
|
|
49
|
+
execFileSync(MUX_BIN, ["-V"], { encoding: "utf8", timeout: 2000 });
|
|
50
|
+
} catch {
|
|
51
|
+
process.stderr.write(
|
|
52
|
+
`ERROR: primary multiplexer 미설치. 설치 방법:\n${formatPsmuxInstallGuidance(" ")}\n`,
|
|
53
|
+
);
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
44
58
|
// ── 메모리 보호 상수 ──
|
|
45
59
|
const MAX_BODY_BYTES = 10240;
|
|
46
60
|
|
|
@@ -59,11 +73,13 @@ const tui = tuiFactory({
|
|
|
59
73
|
worker: workerName,
|
|
60
74
|
openAll: false,
|
|
61
75
|
cwd: process.cwd(),
|
|
76
|
+
targetPane: LEAD_PANE,
|
|
62
77
|
}),
|
|
63
78
|
onOpenAllWorkers: () =>
|
|
64
79
|
openHeadlessDashboardTarget(SESSION, {
|
|
65
80
|
openAll: true,
|
|
66
81
|
cwd: process.cwd(),
|
|
82
|
+
targetPane: LEAD_PANE,
|
|
67
83
|
}),
|
|
68
84
|
});
|
|
69
85
|
const startTime = Date.now();
|
|
@@ -166,9 +182,16 @@ function estimateProgress(lines, context = {}) {
|
|
|
166
182
|
|
|
167
183
|
// ── psmux 래퍼 ──
|
|
168
184
|
function listPanes() {
|
|
185
|
+
if (SOURCE === "files") {
|
|
186
|
+
return Array.from({ length: WORKER_COUNT }, (_, index) => ({
|
|
187
|
+
index: index + 1,
|
|
188
|
+
title: `worker-${index + 1} (daemon)`,
|
|
189
|
+
pid: "",
|
|
190
|
+
}));
|
|
191
|
+
}
|
|
169
192
|
try {
|
|
170
193
|
const out = execFileSync(
|
|
171
|
-
|
|
194
|
+
MUX_BIN,
|
|
172
195
|
[
|
|
173
196
|
"list-panes",
|
|
174
197
|
"-t",
|
|
@@ -192,9 +215,35 @@ function listPanes() {
|
|
|
192
215
|
}
|
|
193
216
|
|
|
194
217
|
function capturePane(paneIdx, lines = 20) {
|
|
218
|
+
if (SOURCE === "files") {
|
|
219
|
+
const base = join(RESULT_DIR, `${SESSION}-worker-${paneIdx}.txt`);
|
|
220
|
+
const artifacts = [
|
|
221
|
+
{ path: base, label: "output" },
|
|
222
|
+
{ path: `${base}.partial`, label: "daemon" },
|
|
223
|
+
{ path: `${base}.err`, label: "stderr" },
|
|
224
|
+
];
|
|
225
|
+
const snapshots = [];
|
|
226
|
+
for (const artifact of artifacts) {
|
|
227
|
+
try {
|
|
228
|
+
const content = readFileSync(artifact.path, "utf8").trim();
|
|
229
|
+
if (!content) continue;
|
|
230
|
+
snapshots.push(
|
|
231
|
+
`[${artifact.label}]\n${content.split("\n").slice(-lines).join("\n")}`,
|
|
232
|
+
);
|
|
233
|
+
} catch {
|
|
234
|
+
/* artifact is optional while the daemon job is starting */
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return (
|
|
238
|
+
snapshots.join("\n") ||
|
|
239
|
+
`worker-${paneIdx} daemon active · waiting for output · ${Math.round(
|
|
240
|
+
(Date.now() - startTime) / 1000,
|
|
241
|
+
)}s`
|
|
242
|
+
);
|
|
243
|
+
}
|
|
195
244
|
try {
|
|
196
245
|
return execFileSync(
|
|
197
|
-
|
|
246
|
+
MUX_BIN,
|
|
198
247
|
["capture-pane", "-t", `${SESSION}:0.${paneIdx}`, "-p"],
|
|
199
248
|
{ encoding: "utf8", timeout: 2000 },
|
|
200
249
|
)
|
|
@@ -368,7 +417,7 @@ function ingest() {
|
|
|
368
417
|
const tokens = extractTokenLabel(snapshot);
|
|
369
418
|
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
|
370
419
|
|
|
371
|
-
if (resultSize > 10 || shellReturned) {
|
|
420
|
+
if (SOURCE !== "files" && (resultSize > 10 || shellReturned)) {
|
|
372
421
|
const resultContent = existsSync(resultFile)
|
|
373
422
|
? readFileSync(resultFile, "utf8")
|
|
374
423
|
: snapshot;
|
|
@@ -410,8 +459,10 @@ function ingest() {
|
|
|
410
459
|
// 진행 중
|
|
411
460
|
const progress = estimateProgress(lines, {
|
|
412
461
|
tokens,
|
|
413
|
-
|
|
414
|
-
|
|
462
|
+
// daemon output files grow while the job is still running; file size is
|
|
463
|
+
// activity evidence, not a completion signal in read-only file mode.
|
|
464
|
+
resultSize: SOURCE === "files" ? 0 : resultSize,
|
|
465
|
+
shellReturned: SOURCE === "files" ? false : shellReturned,
|
|
415
466
|
done: false,
|
|
416
467
|
});
|
|
417
468
|
const verdict = lastLine;
|