@triflux/core 10.1.0 → 10.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/hooks/agent-route-guard.mjs +14 -10
  2. package/hooks/cross-review-tracker.mjs +21 -8
  3. package/hooks/error-context.mjs +20 -8
  4. package/hooks/hook-adaptive-collector.mjs +18 -16
  5. package/hooks/hook-manager.mjs +93 -32
  6. package/hooks/hook-orchestrator.mjs +267 -35
  7. package/hooks/hook-registry.json +343 -256
  8. package/hooks/hooks.json +49 -1
  9. package/hooks/keyword-rules.json +183 -23
  10. package/hooks/lib/resolve-root.mjs +1 -1
  11. package/hooks/mcp-config-watcher.mjs +6 -2
  12. package/hooks/permission-safe-allow.mjs +233 -0
  13. package/hooks/pipeline-stop.mjs +369 -22
  14. package/hooks/post-tool-tips.mjs +306 -0
  15. package/hooks/pre-compact-snapshot.mjs +181 -0
  16. package/hooks/safety-guard.mjs +254 -33
  17. package/hooks/session-end-cleanup.mjs +345 -0
  18. package/hooks/session-start-fast.mjs +214 -0
  19. package/hooks/subagent-tracker.mjs +267 -0
  20. package/hooks/subagent-verifier.mjs +9 -4
  21. package/hub/account-broker.mjs +1036 -66
  22. package/hub/adaptive-diagnostic.mjs +75 -48
  23. package/hub/adaptive-inject.mjs +96 -58
  24. package/hub/adaptive-memory.mjs +156 -42
  25. package/hub/adaptive.mjs +60 -31
  26. package/hub/assign-callbacks.mjs +163 -126
  27. package/hub/bridge.mjs +831 -290
  28. package/hub/cli-adapter-base.mjs +321 -97
  29. package/hub/codex-adapter.mjs +179 -93
  30. package/hub/codex-compat.mjs +4 -4
  31. package/hub/codex-preflight.mjs +113 -37
  32. package/hub/delegator/contracts.mjs +19 -23
  33. package/hub/delegator/index.mjs +14 -14
  34. package/hub/delegator/service.mjs +88 -64
  35. package/hub/delegator/tool-definitions.mjs +35 -35
  36. package/hub/dynamic-routing-engine.mjs +511 -0
  37. package/hub/fullcycle.mjs +34 -18
  38. package/hub/gemini-adapter.mjs +77 -95
  39. package/hub/hitl.mjs +202 -143
  40. package/hub/intent.mjs +167 -38
  41. package/hub/lib/bash-path.mjs +73 -0
  42. package/hub/lib/cache-guard.mjs +43 -17
  43. package/hub/lib/env-detect.mjs +145 -0
  44. package/hub/lib/hosts-compat.mjs +319 -0
  45. package/hub/lib/mcp-response-cache.mjs +239 -0
  46. package/hub/lib/memory-store.mjs +285 -111
  47. package/hub/lib/path-utils.mjs +165 -0
  48. package/hub/lib/phase-manager.mjs +493 -0
  49. package/hub/lib/process-utils.mjs +797 -104
  50. package/hub/lib/prompt-tmp.mjs +33 -0
  51. package/hub/lib/risk-tier.mjs +53 -0
  52. package/hub/lib/spawn-trace.mjs +561 -0
  53. package/hub/lib/ssh-command.mjs +44 -24
  54. package/hub/lib/ssh-retry.mjs +5 -1
  55. package/hub/lib/staged-file-detect.mjs +43 -0
  56. package/hub/lib/state-snapshot.mjs +293 -0
  57. package/hub/lib/tfx-route-args.mjs +309 -0
  58. package/hub/lib/timeline-adapter.mjs +190 -0
  59. package/hub/lib/trace-recorder.mjs +153 -0
  60. package/hub/lib/uuidv7.mjs +4 -3
  61. package/hub/memory-doctor.mjs +266 -106
  62. package/hub/middleware/quota-middleware.mjs +70 -0
  63. package/hub/middleware/request-logger.mjs +61 -34
  64. package/hub/paths.mjs +9 -9
  65. package/hub/pipeline/gates/confidence.mjs +34 -15
  66. package/hub/pipeline/gates/consensus.mjs +27 -15
  67. package/hub/pipeline/gates/index.mjs +7 -3
  68. package/hub/pipeline/gates/selfcheck.mjs +57 -19
  69. package/hub/pipeline/index.mjs +77 -42
  70. package/hub/pipeline/state.mjs +10 -10
  71. package/hub/pipeline/transitions.mjs +40 -23
  72. package/hub/platform.mjs +65 -48
  73. package/hub/quality/deslop.mjs +70 -49
  74. package/hub/reflexion.mjs +87 -13
  75. package/hub/research.mjs +33 -25
  76. package/hub/router.mjs +941 -791
  77. package/hub/routing/complexity.mjs +132 -29
  78. package/hub/routing/index.mjs +17 -12
  79. package/hub/routing/q-learning.mjs +76 -28
  80. package/hub/routing-snapshot.mjs +261 -0
  81. package/hub/session-fingerprint.mjs +127 -61
  82. package/hub/state.mjs +86 -44
  83. package/hub/team/retry-state-machine.mjs +267 -0
  84. package/hub/token-mode.mjs +72 -71
  85. package/hub/workers/worker-utils.mjs +22 -16
  86. package/hud/colors.mjs +27 -9
  87. package/hud/constants.mjs +184 -27
  88. package/hud/context-monitor.mjs +133 -44
  89. package/hud/hud-qos-status.mjs +153 -50
  90. package/hud/mission-board.mjs +56 -0
  91. package/hud/providers/claude.mjs +398 -114
  92. package/hud/providers/codex.mjs +96 -21
  93. package/hud/providers/gemini.mjs +184 -51
  94. package/hud/renderers.mjs +413 -123
  95. package/hud/terminal.mjs +101 -31
  96. package/hud/utils.mjs +81 -39
  97. package/mesh/index.mjs +69 -0
  98. package/mesh/mesh-budget.mjs +137 -0
  99. package/mesh/mesh-heartbeat.mjs +100 -0
  100. package/mesh/mesh-protocol.mjs +96 -0
  101. package/mesh/mesh-queue.mjs +163 -0
  102. package/mesh/mesh-registry.mjs +78 -0
  103. package/mesh/mesh-router.mjs +77 -0
  104. package/package.json +5 -1
  105. package/scripts/lib/agent-json.mjs +27 -0
  106. package/scripts/lib/async.mjs +174 -0
  107. package/scripts/lib/claudemd-scanner.mjs +6 -1
  108. package/scripts/lib/cli-agy.mjs +62 -0
  109. package/scripts/lib/cli-claude.mjs +78 -0
  110. package/scripts/lib/cli-codex.mjs +199 -0
  111. package/scripts/lib/cli-gemini.mjs +67 -0
  112. package/scripts/lib/codex-recovery.sh +50 -0
  113. package/scripts/lib/context.mjs +3 -3
  114. package/scripts/lib/cross-review-utils.mjs +13 -5
  115. package/scripts/lib/doctor-env-checks.mjs +121 -0
  116. package/scripts/lib/dynamic-route-cli.mjs +107 -0
  117. package/scripts/lib/env-probe.mjs +58 -30
  118. package/scripts/lib/env.mjs +14 -0
  119. package/scripts/lib/gemini-profiles.mjs +44 -10
  120. package/scripts/lib/handoff.mjs +33 -17
  121. package/scripts/lib/hook-utils.mjs +8 -6
  122. package/scripts/lib/hub.mjs +48 -0
  123. package/scripts/lib/keyword-rules.mjs +48 -19
  124. package/scripts/lib/logger.mjs +24 -24
  125. package/scripts/lib/mcp-filter.mjs +391 -247
  126. package/scripts/lib/mcp-gateway-health-check.mjs +159 -0
  127. package/scripts/lib/mcp-gateway-servers.mjs +70 -0
  128. package/scripts/lib/mcp-gateway-wrapper-check.mjs +35 -0
  129. package/scripts/lib/mcp-guard-engine.mjs +1281 -115
  130. package/scripts/lib/mcp-health.mjs +773 -0
  131. package/scripts/lib/mcp-manifest.mjs +24 -14
  132. package/scripts/lib/mcp-server-catalog.mjs +355 -118
  133. package/scripts/lib/pid.mjs +63 -0
  134. package/scripts/lib/process-utils.mjs +26 -0
  135. package/scripts/lib/psmux-info.mjs +70 -10
  136. package/scripts/lib/quota.mjs +47 -0
  137. package/scripts/lib/remote-spawn-transfer.mjs +44 -14
  138. package/scripts/lib/skill-state.mjs +220 -0
  139. package/scripts/lib/skill-template.mjs +32 -7
  140. package/scripts/lib/team.mjs +75 -0
  141. package/scripts/lib/timeout.mjs +66 -0
  142. package/scripts/lib/tmp.mjs +26 -0
  143. package/scripts/lib/toml.mjs +113 -0
@@ -2,26 +2,21 @@
2
2
  // PowerShell 호스트에 bash 문법(2>/dev/null, &&, $())을 보내는 사고를 방지한다.
3
3
  // 모든 SSH 명령 생성 코드에서 이 유틸리티를 사용할 것.
4
4
 
5
- import { readFileSync } from "node:fs";
6
- import { join } from "node:path";
5
+ import { readHost, readHosts, resolveHost } from "./hosts-compat.mjs";
7
6
 
8
7
  /** hosts.json 캐시 (프로세스 수명 동안 유지) */
9
8
  let hostsCache = null;
9
+ let hostsCacheRoot = null;
10
10
 
11
11
  /**
12
12
  * hosts.json을 로드하여 캐시한다.
13
13
  * @param {string} [repoRoot] — 프로젝트 루트 경로
14
14
  */
15
15
  function loadHostsCache(repoRoot) {
16
- if (hostsCache) return;
17
- try {
18
- const hostsPath = repoRoot
19
- ? join(repoRoot, "references", "hosts.json")
20
- : join(process.cwd(), "references", "hosts.json");
21
- hostsCache = JSON.parse(readFileSync(hostsPath, "utf8"));
22
- } catch {
23
- hostsCache = { hosts: {} };
24
- }
16
+ const cacheKey = repoRoot || process.cwd();
17
+ if (hostsCache && hostsCacheRoot === cacheKey) return;
18
+ hostsCache = readHosts(repoRoot);
19
+ hostsCacheRoot = cacheKey;
25
20
  }
26
21
 
27
22
  /**
@@ -33,7 +28,7 @@ function loadHostsCache(repoRoot) {
33
28
  export function detectHostOs(hostAlias, repoRoot) {
34
29
  loadHostsCache(repoRoot);
35
30
 
36
- const hostCfg = hostsCache.hosts?.[hostAlias];
31
+ const hostCfg = resolveHost(hostAlias, repoRoot)?.host;
37
32
  if (hostCfg?.os === "windows") return "windows";
38
33
  if (hostCfg?.os) return "posix";
39
34
 
@@ -112,8 +107,10 @@ export function buildSshArgs(host, remoteCmd, opts = {}) {
112
107
  const remoteCmdStr = [cmd, ...quotedArgs].join(" ");
113
108
 
114
109
  const sshArgs = [
115
- "-o", `ConnectTimeout=${connectTimeout}`,
116
- "-o", "BatchMode=yes",
110
+ "-o",
111
+ `ConnectTimeout=${connectTimeout}`,
112
+ "-o",
113
+ "BatchMode=yes",
117
114
  ];
118
115
 
119
116
  if (keepalive) {
@@ -166,7 +163,7 @@ export function validateCommandForOs(command, os) {
166
163
  */
167
164
  export function getHostConfig(hostAlias, repoRoot) {
168
165
  loadHostsCache(repoRoot);
169
- return hostsCache.hosts?.[hostAlias] ?? null;
166
+ return readHost(hostAlias, repoRoot);
170
167
  }
171
168
 
172
169
  /**
@@ -178,20 +175,15 @@ export function getHostConfig(hostAlias, repoRoot) {
178
175
  export function resolveHostAlias(alias, repoRoot) {
179
176
  loadHostsCache(repoRoot);
180
177
 
181
- if (hostsCache.hosts?.[alias]) return alias;
182
-
183
- for (const [name, cfg] of Object.entries(hostsCache.hosts || {})) {
184
- if (cfg.aliases?.includes(alias)) return name;
185
- }
186
-
187
- return null;
178
+ return resolveHost(alias, repoRoot)?.name ?? null;
188
179
  }
189
180
 
190
181
  /**
191
182
  * 특정 capability를 보유한 호스트 목록을 반환한다.
183
+ * 명시적 선택 UI에서 바로 리소스 정보를 보여줄 수 있도록 specs를 평탄화한다.
192
184
  * @param {string} capability — e.g. "codex", "claude", "gpu"
193
185
  * @param {string} [repoRoot]
194
- * @returns {Array<{ name: string, config: object }>}
186
+ * @returns {Array<{ name: string, config: object, specs: { cores?: number, ram_gb?: number } }>}
195
187
  */
196
188
  export function selectHostForCapability(capability, repoRoot) {
197
189
  loadHostsCache(repoRoot);
@@ -199,13 +191,41 @@ export function selectHostForCapability(capability, repoRoot) {
199
191
  const matches = [];
200
192
  for (const [name, cfg] of Object.entries(hostsCache.hosts || {})) {
201
193
  if (cfg.capabilities?.includes(capability)) {
202
- matches.push({ name, config: cfg });
194
+ matches.push({
195
+ name,
196
+ config: cfg,
197
+ specs: {
198
+ cores: cfg.specs?.cores,
199
+ ram_gb: cfg.specs?.ram_gb,
200
+ },
201
+ });
203
202
  }
204
203
  }
205
204
  return matches;
206
205
  }
207
206
 
207
+ /**
208
+ * 특정 capability를 가진 호스트를 리소스 기준으로 정렬해 반환한다.
209
+ * 자동 선택은 하지 않고, 명시적 선택 목록 정렬에만 사용한다.
210
+ * @param {string} capability — e.g. "codex", "claude", "gpu"
211
+ * @param {"cores"|"ram_gb"} sortBy
212
+ * @param {string} [repoRoot]
213
+ * @returns {Array<{ name: string, config: object, specs: { cores?: number, ram_gb?: number } }>}
214
+ */
215
+ export function selectHostByResources(capability, sortBy, repoRoot) {
216
+ if (sortBy !== "cores" && sortBy !== "ram_gb") {
217
+ throw new TypeError('sortBy must be "cores" or "ram_gb"');
218
+ }
219
+
220
+ return selectHostForCapability(capability, repoRoot).sort((a, b) => {
221
+ const aValue = a.specs?.[sortBy] ?? -Infinity;
222
+ const bValue = b.specs?.[sortBy] ?? -Infinity;
223
+ return bValue - aValue;
224
+ });
225
+ }
226
+
208
227
  /** hosts.json 캐시 초기화 (테스트용) */
209
228
  export function resetHostsCache() {
210
229
  hostsCache = null;
230
+ hostsCacheRoot = null;
211
231
  }
@@ -21,7 +21,11 @@ const TRANSIENT_RE =
21
21
  * @returns {string|Buffer} ssh stdout
22
22
  */
23
23
  export function execSshWithRetry(args, opts = {}) {
24
- const { maxRetries = DEFAULT_MAX_RETRIES, baseDelayMs = DEFAULT_BASE_DELAY_MS, ...execOpts } = opts;
24
+ const {
25
+ maxRetries = DEFAULT_MAX_RETRIES,
26
+ baseDelayMs = DEFAULT_BASE_DELAY_MS,
27
+ ...execOpts
28
+ } = opts;
25
29
 
26
30
  let lastError;
27
31
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
@@ -0,0 +1,43 @@
1
+ import { execFileSync } from "node:child_process";
2
+
3
+ export function detectChangedFiles({
4
+ cwd = process.cwd(),
5
+ includeStaged = true,
6
+ includeUnstaged = true,
7
+ includeUntracked = true,
8
+ } = {}) {
9
+ const staged = includeStaged
10
+ ? runGit(cwd, ["diff", "--cached", "--name-only"])
11
+ : [];
12
+ const unstaged = includeUnstaged ? runGit(cwd, ["diff", "--name-only"]) : [];
13
+ const untracked = includeUntracked
14
+ ? runGit(cwd, ["ls-files", "--others", "--exclude-standard"])
15
+ : [];
16
+ const files = [...new Set([...staged, ...unstaged, ...untracked])];
17
+
18
+ return {
19
+ stagedCount: staged.length,
20
+ unstagedCount: unstaged.length,
21
+ untrackedCount: untracked.length,
22
+ totalChanged: files.length,
23
+ files,
24
+ };
25
+ }
26
+
27
+ export function hasCodeChange(opts) {
28
+ return detectChangedFiles(opts).totalChanged > 0;
29
+ }
30
+
31
+ function runGit(cwd, args) {
32
+ try {
33
+ return execFileSync("git", args, {
34
+ cwd,
35
+ encoding: "utf8",
36
+ stdio: ["ignore", "pipe", "ignore"],
37
+ })
38
+ .split(/\r?\n/)
39
+ .filter(Boolean);
40
+ } catch {
41
+ return [];
42
+ }
43
+ }
@@ -0,0 +1,293 @@
1
+ import { execFile } from "node:child_process";
2
+ import {
3
+ copyFile,
4
+ mkdir,
5
+ readdir,
6
+ rename,
7
+ rm,
8
+ stat,
9
+ writeFile,
10
+ } from "node:fs/promises";
11
+ import { tmpdir } from "node:os";
12
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
13
+ import { promisify } from "node:util";
14
+
15
+ const execFileAsync = promisify(execFile);
16
+ const DEFAULT_MAX_SNAPSHOTS = 10;
17
+ const DAY_MS = 24 * 60 * 60 * 1000;
18
+
19
+ export const CODEX_STATE_INCLUDES = Object.freeze([
20
+ "config.toml",
21
+ "AGENTS.md",
22
+ "skills",
23
+ "agents",
24
+ "prompts",
25
+ "plugins",
26
+ ]);
27
+
28
+ export const CODEX_STATE_EXCLUDES = Object.freeze([
29
+ "*.sqlite*",
30
+ ".sandbox*",
31
+ ".tmp",
32
+ "_archived_skills",
33
+ "memories",
34
+ "cache",
35
+ "log",
36
+ "logs",
37
+ "sessions",
38
+ "auth.json",
39
+ ".credentials.json",
40
+ "*.bak*",
41
+ "*.tmp-*",
42
+ "cap_sid",
43
+ "installation_id",
44
+ "history.jsonl",
45
+ "models_cache.json",
46
+ ]);
47
+
48
+ export const GEMINI_STATE_INCLUDES = Object.freeze([
49
+ "settings.json",
50
+ "settings.local.json",
51
+ "GEMINI.md",
52
+ "commands",
53
+ "extensions",
54
+ "plugins",
55
+ "skills",
56
+ "agents",
57
+ ]);
58
+
59
+ export const GEMINI_STATE_EXCLUDES = Object.freeze([
60
+ "*.sqlite*",
61
+ "cache",
62
+ "log",
63
+ "logs",
64
+ "sessions",
65
+ "auth.json",
66
+ ".credentials.json",
67
+ "*.bak*",
68
+ ]);
69
+
70
+ export const STATE_SNAPSHOT_THRESHOLD_MS = DAY_MS;
71
+ export const STATE_SNAPSHOT_MAX_SNAPSHOTS = DEFAULT_MAX_SNAPSHOTS;
72
+
73
+ function normalizePath(path) {
74
+ return String(path || "")
75
+ .replace(/\\/gu, "/")
76
+ .replace(/^\/+/u, "");
77
+ }
78
+
79
+ function globToRegExp(pattern) {
80
+ const escaped = String(pattern).replace(/[.+^${}()|[\]\\]/gu, "\\$&");
81
+ return new RegExp(`^${escaped.replace(/\*/gu, ".*")}$`, "u");
82
+ }
83
+
84
+ function compileExclude(pattern) {
85
+ const text = normalizePath(pattern).replace(/\/+$/u, "");
86
+ if (text.includes("*")) {
87
+ const regex = globToRegExp(text);
88
+ return (relativePath) => {
89
+ const normalized = normalizePath(relativePath);
90
+ return normalized.split("/").some((part) => regex.test(part));
91
+ };
92
+ }
93
+
94
+ return (relativePath) => {
95
+ const normalized = normalizePath(relativePath);
96
+ return normalized.split("/").includes(text);
97
+ };
98
+ }
99
+
100
+ function isSubpath(parent, child) {
101
+ const rel = relative(parent, child);
102
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
103
+ }
104
+
105
+ async function pathStats(path) {
106
+ try {
107
+ return await stat(path);
108
+ } catch (error) {
109
+ if (error?.code === "ENOENT") return null;
110
+ throw error;
111
+ }
112
+ }
113
+
114
+ async function collectFiles({ sourceDir, includes, excludeMatchers }) {
115
+ const sourceRoot = resolve(sourceDir);
116
+ const files = [];
117
+
118
+ async function visit(absPath, relativePath) {
119
+ if (excludeMatchers.some((matcher) => matcher(relativePath))) return;
120
+
121
+ const info = await pathStats(absPath);
122
+ if (!info) return;
123
+ if (info.isDirectory()) {
124
+ const children = await readdir(absPath, { withFileTypes: true });
125
+ for (const child of children) {
126
+ await visit(join(absPath, child.name), join(relativePath, child.name));
127
+ }
128
+ return;
129
+ }
130
+ if (info.isFile()) {
131
+ files.push({
132
+ absPath,
133
+ relativePath: normalizePath(relativePath),
134
+ size: info.size,
135
+ });
136
+ }
137
+ }
138
+
139
+ for (const include of includes || []) {
140
+ const relativeInclude = normalizePath(include);
141
+ if (!relativeInclude || relativeInclude.startsWith("../")) continue;
142
+ const absPath = resolve(sourceRoot, relativeInclude);
143
+ if (!isSubpath(sourceRoot, absPath)) continue;
144
+ await visit(absPath, relativeInclude);
145
+ }
146
+
147
+ files.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
148
+ return files;
149
+ }
150
+
151
+ async function listSnapshots(destDir) {
152
+ const names = await readdir(destDir).catch((error) => {
153
+ if (error?.code === "ENOENT") return [];
154
+ throw error;
155
+ });
156
+ const snapshots = [];
157
+ for (const name of names) {
158
+ if (!name.endsWith(".tar.gz")) continue;
159
+ const path = join(destDir, name);
160
+ const info = await pathStats(path);
161
+ if (info?.isFile()) snapshots.push({ name, path, mtimeMs: info.mtimeMs });
162
+ }
163
+ snapshots.sort(
164
+ (a, b) => b.mtimeMs - a.mtimeMs || b.name.localeCompare(a.name),
165
+ );
166
+ return snapshots;
167
+ }
168
+
169
+ async function copyToStaging(files, stagingDir) {
170
+ for (const file of files) {
171
+ const targetPath = join(stagingDir, ...file.relativePath.split("/"));
172
+ await mkdir(dirname(targetPath), { recursive: true });
173
+ await copyFile(file.absPath, targetPath);
174
+ }
175
+ }
176
+
177
+ async function createArchive({ stagingDir, archivePath, files }) {
178
+ const listPath = join(stagingDir, ".snapshot-files");
179
+ const archiveName = normalizePath(relative(stagingDir, archivePath));
180
+ await writeFile(
181
+ listPath,
182
+ `${files.map((file) => file.relativePath).join("\n")}\n`,
183
+ "utf8",
184
+ );
185
+ await execFileAsync(
186
+ "tar",
187
+ ["-czf", archiveName, "-C", ".", "-T", ".snapshot-files"],
188
+ {
189
+ cwd: stagingDir,
190
+ windowsHide: true,
191
+ },
192
+ );
193
+ await rm(listPath, { force: true });
194
+ }
195
+
196
+ async function pruneSnapshots(destDir, maxSnapshots) {
197
+ const snapshots = await listSnapshots(destDir);
198
+ const keep = Math.max(1, Number(maxSnapshots) || DEFAULT_MAX_SNAPSHOTS);
199
+ for (const snapshot of snapshots.slice(keep)) {
200
+ await rm(snapshot.path, { force: true });
201
+ }
202
+ }
203
+
204
+ function formatStamp(date) {
205
+ return date
206
+ .toISOString()
207
+ .replace(/[-:]/gu, "")
208
+ .replace(/\.\d{3}Z$/u, "Z");
209
+ }
210
+
211
+ function uniqueSuffix() {
212
+ return `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
213
+ }
214
+
215
+ /**
216
+ * Snapshot selected user state into a rolling tar.gz archive.
217
+ *
218
+ * @param {object} options
219
+ * @param {string} options.sourceDir
220
+ * @param {string} options.destDir
221
+ * @param {string[]} options.includes
222
+ * @param {string[]} options.excludes
223
+ * @param {number} options.thresholdMs
224
+ * @param {number} [options.maxSnapshots=10]
225
+ * @returns {Promise<{skipped: boolean, reason?: string, path?: string, sizeBytes?: number, fileCount?: number}>}
226
+ */
227
+ export async function snapshotState({
228
+ sourceDir,
229
+ destDir,
230
+ includes,
231
+ excludes = [],
232
+ thresholdMs = 0,
233
+ maxSnapshots = DEFAULT_MAX_SNAPSHOTS,
234
+ }) {
235
+ const sourceRoot = resolve(sourceDir || "");
236
+ const destRoot = resolve(destDir || "");
237
+ const sourceInfo = await pathStats(sourceRoot);
238
+ if (!sourceInfo?.isDirectory()) {
239
+ return { skipped: true, reason: "source-missing" };
240
+ }
241
+
242
+ await mkdir(destRoot, { recursive: true });
243
+ const snapshots = await listSnapshots(destRoot);
244
+ const newest = snapshots[0];
245
+ if (
246
+ newest &&
247
+ Number(thresholdMs) > 0 &&
248
+ Date.now() - newest.mtimeMs < Number(thresholdMs)
249
+ ) {
250
+ return { skipped: true, reason: "threshold", path: newest.path };
251
+ }
252
+
253
+ const excludeMatchers = excludes.map((pattern) => compileExclude(pattern));
254
+ const files = await collectFiles({
255
+ sourceDir: sourceRoot,
256
+ includes,
257
+ excludeMatchers,
258
+ });
259
+ if (files.length === 0) {
260
+ return { skipped: true, reason: "empty" };
261
+ }
262
+
263
+ const suffix = uniqueSuffix();
264
+ const stagingDir = join(tmpdir(), `tfx-state-snapshot-${suffix}`);
265
+ const tempArchivePath = join(stagingDir, `.state-${suffix}.tar.gz.tmp`);
266
+ const finalArchivePath = join(
267
+ destRoot,
268
+ `state-${formatStamp(new Date())}-${suffix}.tar.gz`,
269
+ );
270
+
271
+ try {
272
+ await mkdir(stagingDir, { recursive: true });
273
+ await copyToStaging(files, stagingDir);
274
+ await createArchive({
275
+ stagingDir,
276
+ archivePath: tempArchivePath,
277
+ files,
278
+ });
279
+ await rename(tempArchivePath, finalArchivePath);
280
+ await pruneSnapshots(destRoot, maxSnapshots);
281
+ const archiveInfo = await stat(finalArchivePath);
282
+ const sizeBytes = files.reduce((sum, file) => sum + file.size, 0);
283
+ return {
284
+ skipped: false,
285
+ path: finalArchivePath,
286
+ sizeBytes: archiveInfo.size || sizeBytes,
287
+ fileCount: files.length,
288
+ };
289
+ } finally {
290
+ await rm(stagingDir, { recursive: true, force: true });
291
+ await rm(tempArchivePath, { force: true });
292
+ }
293
+ }