triflux 10.19.0 → 10.20.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +34 -0
- package/.claude-plugin/plugin.json +22 -0
- package/bin/triflux.mjs +2 -0
- package/config/mcp-registry.json +44 -0
- package/hooks/hook-registry.json +13 -0
- package/hooks/hooks.json +12 -0
- package/hooks/pre-compact-snapshot.mjs +181 -0
- package/hub/team/synapse-cli.mjs +243 -6
- package/package.json +67 -23
- package/scripts/mcp-gateway-ensure.mjs +14 -5
- package/scripts/mcp-gateway-integration-test.mjs +27 -11
- package/scripts/mcp-gateway-start.mjs +86 -34
- package/scripts/session-stale-cleanup.mjs +111 -2
- package/skills/tfx-harness/SKILL.md +85 -0
- package/skills/tfx-harness/SKILL.md.tmpl +75 -0
- package/skills/tfx-harness/skill.json +16 -0
- package/skills/tfx-research/SKILL.md +1 -1
- package/tui/codex-profile.mjs +459 -0
- package/tui/core.mjs +266 -0
- package/tui/doctor.mjs +375 -0
- package/tui/gemini-profile.mjs +299 -0
- package/tui/monitor-data.mjs +152 -0
- package/tui/monitor.mjs +317 -0
- package/tui/setup.mjs +599 -0
- package/CLAUDE.md +0 -170
- package/references/cli-parameter-reference.md +0 -240
- package/references/codex-plugin-cc-analysis.md +0 -706
- package/references/codex-plugin-cc-code-patterns.md +0 -468
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
|
|
3
|
+
"name": "triflux",
|
|
4
|
+
"description": "CLI-first multi-model orchestrator — Codex/Gemini/Claude routing with DAG execution, auto-triage, and cost optimization",
|
|
5
|
+
"owner": {
|
|
6
|
+
"name": "tellang"
|
|
7
|
+
},
|
|
8
|
+
"plugins": [
|
|
9
|
+
{
|
|
10
|
+
"name": "triflux",
|
|
11
|
+
"description": "Tri-CLI orchestrator for Claude Code. Routes tasks across Claude + Codex + Gemini with consensus intelligence, natural language routing, 42 skills, and cross-model review.",
|
|
12
|
+
"version": "10.20.1",
|
|
13
|
+
"author": {
|
|
14
|
+
"name": "tellang"
|
|
15
|
+
},
|
|
16
|
+
"source": {
|
|
17
|
+
"source": "npm",
|
|
18
|
+
"package": "triflux"
|
|
19
|
+
},
|
|
20
|
+
"category": "productivity",
|
|
21
|
+
"homepage": "https://github.com/tellang/triflux",
|
|
22
|
+
"tags": [
|
|
23
|
+
"multi-model",
|
|
24
|
+
"codex",
|
|
25
|
+
"gemini",
|
|
26
|
+
"cli-routing",
|
|
27
|
+
"orchestration",
|
|
28
|
+
"cost-optimization",
|
|
29
|
+
"dag-execution"
|
|
30
|
+
]
|
|
31
|
+
}
|
|
32
|
+
],
|
|
33
|
+
"version": "10.20.1"
|
|
34
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "triflux",
|
|
3
|
+
"version": "10.20.1",
|
|
4
|
+
"description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Gemini, and Claude",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "tellang"
|
|
7
|
+
},
|
|
8
|
+
"repository": "https://github.com/tellang/triflux",
|
|
9
|
+
"homepage": "https://github.com/tellang/triflux",
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"keywords": [
|
|
12
|
+
"claude-code",
|
|
13
|
+
"plugin",
|
|
14
|
+
"codex",
|
|
15
|
+
"gemini",
|
|
16
|
+
"cli-routing",
|
|
17
|
+
"orchestration",
|
|
18
|
+
"multi-model"
|
|
19
|
+
],
|
|
20
|
+
"skills": "./skills/",
|
|
21
|
+
"hooks": "./hooks/hooks.json"
|
|
22
|
+
}
|
package/bin/triflux.mjs
CHANGED
|
@@ -77,6 +77,7 @@ import {
|
|
|
77
77
|
getVersion,
|
|
78
78
|
getWindowsHubAutostartStatus,
|
|
79
79
|
hasProfileSection,
|
|
80
|
+
isSetupUserStateFile,
|
|
80
81
|
LEGACY_CODEX_MODELS,
|
|
81
82
|
REQUIRED_CODEX_PROFILES,
|
|
82
83
|
replaceProfileSection,
|
|
@@ -1206,6 +1207,7 @@ function cmdSetup(options = {}) {
|
|
|
1206
1207
|
if (existsSync(refSrc)) {
|
|
1207
1208
|
mkdirSync(refDst, { recursive: true });
|
|
1208
1209
|
for (const refFile of readdirSync(refSrc)) {
|
|
1210
|
+
if (isSetupUserStateFile(refFile)) continue;
|
|
1209
1211
|
const rSrc = join(refSrc, refFile);
|
|
1210
1212
|
const rDst = join(refDst, refFile);
|
|
1211
1213
|
if (statSync(rSrc).isFile()) {
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "mcp-registry-schema",
|
|
3
|
+
"version": 1,
|
|
4
|
+
"description": "MCP 서버 중앙 레지스트리 — 진실의 원천",
|
|
5
|
+
"defaults": {
|
|
6
|
+
"transport": "hub-url",
|
|
7
|
+
"hub_base": "http://127.0.0.1:27888"
|
|
8
|
+
},
|
|
9
|
+
"policy_notes": {
|
|
10
|
+
"transport": "Server transport accepts \"hub-url\" for the existing triflux Hub URL flow or \"http\" for direct Streamable HTTP MCP endpoints. Direct stdio registration via command/args is intentionally unsupported.",
|
|
11
|
+
"headers": "Optional headers are allowed only for HTTP-compatible transports. Each header value must be a descriptor: {\"value\":\"literal\"} for non-secret static values, {\"env\":\"ENV_VAR_NAME\"} for secrets resolved at sync/runtime, or {\"env\":\"ENV_VAR_NAME\",\"prefix\":\"Bearer \"} for common authorization formats.",
|
|
12
|
+
"secret_safety": "Resolved secret values must not be written back to this registry file. Missing env vars warn during sync and do not emit empty secret headers.",
|
|
13
|
+
"sync_denylist": "Array of client:server strings skipped by proactive registry sync, for example gemini:tfx-hub."
|
|
14
|
+
},
|
|
15
|
+
"servers": {
|
|
16
|
+
"tfx-hub": {
|
|
17
|
+
"transport": "hub-url",
|
|
18
|
+
"url": "http://127.0.0.1:27888/mcp",
|
|
19
|
+
"safe": true,
|
|
20
|
+
"targets": ["claude", "gemini", "codex"],
|
|
21
|
+
"description": "triflux Hub MCP 서버"
|
|
22
|
+
},
|
|
23
|
+
"context7": {
|
|
24
|
+
"transport": "http",
|
|
25
|
+
"url": "https://mcp.context7.com/mcp",
|
|
26
|
+
"safe": true,
|
|
27
|
+
"targets": ["claude", "gemini", "codex"],
|
|
28
|
+
"description": "Upstash Context7 — 라이브러리 문서/코드 컨텍스트 (HTTP MCP, API key 불필요)"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"policies": {
|
|
32
|
+
"stdio_action": "replace-with-hub",
|
|
33
|
+
"unknown_server_action": "warn",
|
|
34
|
+
"sync_denylist": [],
|
|
35
|
+
"watched_paths": [
|
|
36
|
+
"~/.gemini/settings.json",
|
|
37
|
+
"~/.codex/config.toml",
|
|
38
|
+
"~/.claude/settings.json",
|
|
39
|
+
"~/.claude/settings.local.json",
|
|
40
|
+
".claude/mcp.json",
|
|
41
|
+
".mcp.json"
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
}
|
package/hooks/hook-registry.json
CHANGED
|
@@ -264,6 +264,19 @@
|
|
|
264
264
|
"blocking": false,
|
|
265
265
|
"description": "서브에이전트 결과 품질 체크"
|
|
266
266
|
}
|
|
267
|
+
],
|
|
268
|
+
"PreCompact": [
|
|
269
|
+
{
|
|
270
|
+
"id": "tfx-pre-compact-snapshot",
|
|
271
|
+
"source": "triflux",
|
|
272
|
+
"matcher": "*",
|
|
273
|
+
"command": "node \"${PLUGIN_ROOT}/hooks/pre-compact-snapshot.mjs\"",
|
|
274
|
+
"priority": 0,
|
|
275
|
+
"enabled": true,
|
|
276
|
+
"timeout": 3,
|
|
277
|
+
"blocking": false,
|
|
278
|
+
"description": "compact 직전 repo-local 진행 상태 스냅샷 주입 (#245)"
|
|
279
|
+
}
|
|
267
280
|
]
|
|
268
281
|
}
|
|
269
282
|
}
|
package/hooks/hooks.json
CHANGED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// hooks/pre-compact-snapshot.mjs — PreCompact hook
|
|
3
|
+
//
|
|
4
|
+
// Compaction 직전에 짧은 상태 스냅샷을 주입한다. 민감 데이터는 읽지 않고,
|
|
5
|
+
// repo-local 상태와 git 요약만 3KB 이하로 제한한다.
|
|
6
|
+
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
const MAX_CONTEXT_BYTES = 3000;
|
|
13
|
+
const STALE_RUN_MS = 30 * 60 * 1000;
|
|
14
|
+
const PROJECT_ROOT = process.env.CLAUDE_CWD || process.cwd();
|
|
15
|
+
|
|
16
|
+
function readStdinJson() {
|
|
17
|
+
try {
|
|
18
|
+
const raw = readFileSync(0, "utf8");
|
|
19
|
+
return raw.trim() ? JSON.parse(raw) : {};
|
|
20
|
+
} catch {
|
|
21
|
+
return {};
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function git(args) {
|
|
26
|
+
try {
|
|
27
|
+
return execFileSync("git", args, {
|
|
28
|
+
cwd: PROJECT_ROOT,
|
|
29
|
+
encoding: "utf8",
|
|
30
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
31
|
+
timeout: 3000,
|
|
32
|
+
}).trim();
|
|
33
|
+
} catch {
|
|
34
|
+
return "";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function summarizeGit() {
|
|
39
|
+
const branch =
|
|
40
|
+
git(["branch", "--show-current"]) || git(["rev-parse", "--short", "HEAD"]);
|
|
41
|
+
const status = git(["status", "--short"]);
|
|
42
|
+
const rows = status ? status.split(/\r?\n/).filter(Boolean) : [];
|
|
43
|
+
return {
|
|
44
|
+
branch: branch || "unknown",
|
|
45
|
+
dirty: rows.length,
|
|
46
|
+
sample: rows.slice(0, 8),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function readJsonIfSmall(path, maxBytes = 24_000) {
|
|
51
|
+
try {
|
|
52
|
+
if (!existsSync(path)) return null;
|
|
53
|
+
if (statSync(path).size > maxBytes) return null;
|
|
54
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function summarizeModeState(root) {
|
|
61
|
+
const stateDir = join(root, ".omx", "state");
|
|
62
|
+
if (!existsSync(stateDir)) return [];
|
|
63
|
+
try {
|
|
64
|
+
return readdirSync(stateDir)
|
|
65
|
+
.filter((name) => name.endsWith("-state.json"))
|
|
66
|
+
.flatMap((name) => {
|
|
67
|
+
const state = readJsonIfSmall(join(stateDir, name));
|
|
68
|
+
if (!state || state.active === false) return [];
|
|
69
|
+
return [
|
|
70
|
+
{
|
|
71
|
+
mode: state.mode || name.replace(/-state\.json$/, ""),
|
|
72
|
+
phase: state.current_phase || state.phase || null,
|
|
73
|
+
active: state.active !== false,
|
|
74
|
+
},
|
|
75
|
+
];
|
|
76
|
+
})
|
|
77
|
+
.slice(0, 8);
|
|
78
|
+
} catch {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function summarizeRetryState(input) {
|
|
84
|
+
const sessionId = input.session_id || input.sessionId || "";
|
|
85
|
+
if (!sessionId) return null;
|
|
86
|
+
const candidates = [
|
|
87
|
+
join(PROJECT_ROOT, ".omc", "state", `retry-${sessionId}.json`),
|
|
88
|
+
join(homedir(), ".omc", "state", `retry-${sessionId}.json`),
|
|
89
|
+
];
|
|
90
|
+
for (const path of candidates) {
|
|
91
|
+
const state = readJsonIfSmall(path);
|
|
92
|
+
if (!state) continue;
|
|
93
|
+
return {
|
|
94
|
+
phase: state.phase || state.current_phase || null,
|
|
95
|
+
iteration: state.iteration ?? state.retry_count ?? null,
|
|
96
|
+
max: state.max_iterations ?? state.max ?? null,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function countStaleSwarmRuns(nowMs = Date.now()) {
|
|
103
|
+
const logsRoot = join(PROJECT_ROOT, ".triflux", "swarm-logs");
|
|
104
|
+
if (!existsSync(logsRoot)) return 0;
|
|
105
|
+
try {
|
|
106
|
+
return readdirSync(logsRoot, { withFileTypes: true }).filter((entry) => {
|
|
107
|
+
if (!entry.isDirectory() || !entry.name.startsWith("run-")) return false;
|
|
108
|
+
const eventPath = join(logsRoot, entry.name, "swarm-events.jsonl");
|
|
109
|
+
if (!existsSync(eventPath)) return false;
|
|
110
|
+
try {
|
|
111
|
+
return nowMs - statSync(eventPath).mtimeMs > STALE_RUN_MS;
|
|
112
|
+
} catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}).length;
|
|
116
|
+
} catch {
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function truncateUtf8(text, maxBytes) {
|
|
122
|
+
let output = text;
|
|
123
|
+
while (Buffer.byteLength(output, "utf8") > maxBytes) {
|
|
124
|
+
output = output.slice(0, Math.max(0, output.length - 200));
|
|
125
|
+
}
|
|
126
|
+
return output;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function buildSnapshot(input = readStdinJson()) {
|
|
130
|
+
const gitState = summarizeGit();
|
|
131
|
+
const activeModes = summarizeModeState(PROJECT_ROOT);
|
|
132
|
+
const retry = summarizeRetryState(input);
|
|
133
|
+
const staleSwarmRuns = countStaleSwarmRuns();
|
|
134
|
+
|
|
135
|
+
const lines = [
|
|
136
|
+
"[triflux pre-compact snapshot]",
|
|
137
|
+
`cwd: ${PROJECT_ROOT}`,
|
|
138
|
+
`git: ${gitState.branch}; dirty=${gitState.dirty}`,
|
|
139
|
+
];
|
|
140
|
+
if (gitState.sample.length) {
|
|
141
|
+
lines.push(`dirty_sample: ${gitState.sample.join(" | ")}`);
|
|
142
|
+
}
|
|
143
|
+
if (activeModes.length) {
|
|
144
|
+
lines.push(
|
|
145
|
+
`active_modes: ${activeModes
|
|
146
|
+
.map((m) => `${m.mode}${m.phase ? `:${m.phase}` : ""}`)
|
|
147
|
+
.join(", ")}`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
if (retry) {
|
|
151
|
+
lines.push(
|
|
152
|
+
`retry: phase=${retry.phase || "-"} iteration=${retry.iteration ?? "-"}/${retry.max ?? "-"}`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
if (staleSwarmRuns > 0) {
|
|
156
|
+
lines.push(`stale_swarm_runs: ${staleSwarmRuns}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return truncateUtf8(lines.join("\n"), MAX_CONTEXT_BYTES);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function main() {
|
|
163
|
+
const snapshot = buildSnapshot();
|
|
164
|
+
if (!snapshot.trim()) return;
|
|
165
|
+
process.stdout.write(
|
|
166
|
+
JSON.stringify({
|
|
167
|
+
hookSpecificOutput: {
|
|
168
|
+
hookEventName: "PreCompact",
|
|
169
|
+
additionalContext: snapshot,
|
|
170
|
+
},
|
|
171
|
+
}),
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
if (import.meta.url.endsWith(process.argv[1]?.split(/[\\/]/).pop() || "")) {
|
|
177
|
+
main();
|
|
178
|
+
}
|
|
179
|
+
} catch {
|
|
180
|
+
process.exit(0);
|
|
181
|
+
}
|
package/hub/team/synapse-cli.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// so the CLI works even when Hub is offline.
|
|
5
5
|
|
|
6
6
|
import { execFile } from "node:child_process";
|
|
7
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
7
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
9
|
import { join, resolve } from "node:path";
|
|
10
10
|
|
|
@@ -15,6 +15,15 @@ const DEFAULT_REGISTRY_CANDIDATES = [
|
|
|
15
15
|
".triflux/synapse/registry.json",
|
|
16
16
|
join(homedir(), ".claude", "cache", "tfx-hub", "synapse-registry.json"),
|
|
17
17
|
];
|
|
18
|
+
const DEFAULT_SWARM_LOGS_DIR = ".triflux/swarm-logs";
|
|
19
|
+
const STALE_AFTER_MS = 30 * 60 * 1000;
|
|
20
|
+
const STATUS_PRIORITY = {
|
|
21
|
+
active: 3,
|
|
22
|
+
running: 3,
|
|
23
|
+
completed: 2,
|
|
24
|
+
failed: 2,
|
|
25
|
+
stale: 1,
|
|
26
|
+
};
|
|
18
27
|
|
|
19
28
|
function gitExec(args, cwd) {
|
|
20
29
|
return new Promise((res, rej) => {
|
|
@@ -55,10 +64,228 @@ function loadRegistrySnapshot(path) {
|
|
|
55
64
|
}
|
|
56
65
|
}
|
|
57
66
|
|
|
67
|
+
function parseJsonLines(path) {
|
|
68
|
+
try {
|
|
69
|
+
return readFileSync(path, "utf8")
|
|
70
|
+
.split(/\r?\n/)
|
|
71
|
+
.map((line) => line.trim())
|
|
72
|
+
.filter(Boolean)
|
|
73
|
+
.flatMap((line) => {
|
|
74
|
+
try {
|
|
75
|
+
return [JSON.parse(line)];
|
|
76
|
+
} catch {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
} catch {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function normalizeRunId(name) {
|
|
86
|
+
return String(name || "").replace(/^run-/, "");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function eventTimeMs(event) {
|
|
90
|
+
const ms = Date.parse(String(event?.ts || ""));
|
|
91
|
+
return Number.isFinite(ms) ? ms : 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function deriveLogStatus(events, lastHeartbeatMs, nowMs) {
|
|
95
|
+
const stateEvents = events.filter((e) => e.event === "swarm_state");
|
|
96
|
+
const lastState = stateEvents[stateEvents.length - 1]?.to;
|
|
97
|
+
if (lastState === "completed" || lastState === "failed") return lastState;
|
|
98
|
+
if (events.some((e) => e.event === "integration_complete")) {
|
|
99
|
+
const lastIntegration = [...events]
|
|
100
|
+
.reverse()
|
|
101
|
+
.find((e) => e.event === "integration_complete");
|
|
102
|
+
if (
|
|
103
|
+
Array.isArray(lastIntegration?.failed) &&
|
|
104
|
+
lastIntegration.failed.length
|
|
105
|
+
) {
|
|
106
|
+
return "failed";
|
|
107
|
+
}
|
|
108
|
+
return "completed";
|
|
109
|
+
}
|
|
110
|
+
if (nowMs - lastHeartbeatMs > STALE_AFTER_MS) return "stale";
|
|
111
|
+
return "active";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function buildLogSession(runDir, nowMs = Date.now()) {
|
|
115
|
+
const eventPath = join(runDir, "swarm-events.jsonl");
|
|
116
|
+
if (!existsSync(eventPath)) return null;
|
|
117
|
+
const events = parseJsonLines(eventPath);
|
|
118
|
+
if (events.length === 0) return null;
|
|
119
|
+
|
|
120
|
+
let statMs = 0;
|
|
121
|
+
try {
|
|
122
|
+
statMs = statSync(eventPath).mtimeMs;
|
|
123
|
+
} catch {}
|
|
124
|
+
|
|
125
|
+
const eventMs = events.map(eventTimeMs).filter((ms) => ms > 0);
|
|
126
|
+
const lastEventMs = eventMs.length ? Math.max(...eventMs) : statMs;
|
|
127
|
+
const launched = new Set();
|
|
128
|
+
const completed = new Set();
|
|
129
|
+
const failed = new Set();
|
|
130
|
+
for (const event of events) {
|
|
131
|
+
if (event.event === "shard_launched" && event.shard) {
|
|
132
|
+
launched.add(event.shard);
|
|
133
|
+
} else if (event.event === "shard_completed" && event.shard) {
|
|
134
|
+
completed.add(event.shard);
|
|
135
|
+
} else if (
|
|
136
|
+
(event.event === "shard_failed" ||
|
|
137
|
+
event.event === "shard_launch_failed") &&
|
|
138
|
+
event.shard
|
|
139
|
+
) {
|
|
140
|
+
failed.add(event.shard);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const runId = normalizeRunId(runDir.split(/[\\/]/).pop());
|
|
145
|
+
const totalShards = launched.size || completed.size + failed.size;
|
|
146
|
+
const aliveWorkers = [...launched].filter(
|
|
147
|
+
(name) => !completed.has(name) && !failed.has(name),
|
|
148
|
+
).length;
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
sessionId: runId,
|
|
152
|
+
runId,
|
|
153
|
+
host: "-",
|
|
154
|
+
branch: "-",
|
|
155
|
+
dirtyFiles: [],
|
|
156
|
+
status: deriveLogStatus(events, lastEventMs || nowMs, nowMs),
|
|
157
|
+
taskSummary: "swarm log run",
|
|
158
|
+
source: "logs",
|
|
159
|
+
shards: totalShards ? `${completed.size}/${totalShards}` : "-",
|
|
160
|
+
workers: totalShards ? `${aliveWorkers} alive` : "-",
|
|
161
|
+
lastHeartbeat: lastEventMs || null,
|
|
162
|
+
logDir: runDir,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function loadSwarmLogSessions(logsDir, opts = {}) {
|
|
167
|
+
const nowMs = opts.nowMs ?? Date.now();
|
|
168
|
+
const root = logsDir || DEFAULT_SWARM_LOGS_DIR;
|
|
169
|
+
if (!existsSync(root)) return [];
|
|
170
|
+
try {
|
|
171
|
+
return readdirSync(root, { withFileTypes: true })
|
|
172
|
+
.filter((entry) => entry.isDirectory() && entry.name.startsWith("run-"))
|
|
173
|
+
.map((entry) => buildLogSession(join(root, entry.name), nowMs))
|
|
174
|
+
.filter(Boolean);
|
|
175
|
+
} catch {
|
|
176
|
+
return [];
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function statusPriority(status) {
|
|
181
|
+
return STATUS_PRIORITY[status] || 0;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function mergeSession(existing, next) {
|
|
185
|
+
const status =
|
|
186
|
+
statusPriority(next.status) > statusPriority(existing.status)
|
|
187
|
+
? next.status
|
|
188
|
+
: existing.status;
|
|
189
|
+
const source =
|
|
190
|
+
existing.source && next.source && existing.source !== next.source
|
|
191
|
+
? "synapse + logs"
|
|
192
|
+
: existing.source || next.source || "synapse";
|
|
193
|
+
return {
|
|
194
|
+
...next,
|
|
195
|
+
...existing,
|
|
196
|
+
status,
|
|
197
|
+
source,
|
|
198
|
+
shards: next.shards || existing.shards,
|
|
199
|
+
workers: next.workers || existing.workers,
|
|
200
|
+
lastHeartbeat: Math.max(
|
|
201
|
+
Number(existing.lastHeartbeat || 0),
|
|
202
|
+
Number(next.lastHeartbeat || 0),
|
|
203
|
+
),
|
|
204
|
+
logDir: existing.logDir || next.logDir,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function mergeRegistryAndLogSessions(registrySessions, logSessions) {
|
|
209
|
+
const merged = new Map();
|
|
210
|
+
for (const session of registrySessions) {
|
|
211
|
+
const key = String(session.runId || session.sessionId || "");
|
|
212
|
+
if (!key) continue;
|
|
213
|
+
merged.set(key, {
|
|
214
|
+
...session,
|
|
215
|
+
source: session.source || "synapse",
|
|
216
|
+
lastHeartbeat: session.lastHeartbeat || null,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
for (const session of logSessions) {
|
|
220
|
+
const key = String(session.runId || session.sessionId || "");
|
|
221
|
+
if (!key) continue;
|
|
222
|
+
const existing = merged.get(key);
|
|
223
|
+
merged.set(key, existing ? mergeSession(existing, session) : session);
|
|
224
|
+
}
|
|
225
|
+
return [...merged.values()].sort((left, right) => {
|
|
226
|
+
const rightTs = Number(right.lastHeartbeat || 0);
|
|
227
|
+
const leftTs = Number(left.lastHeartbeat || 0);
|
|
228
|
+
return rightTs - leftTs;
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function formatAge(ts, nowMs = Date.now()) {
|
|
233
|
+
if (!Number.isFinite(Number(ts)) || Number(ts) <= 0) return "-";
|
|
234
|
+
const seconds = Math.max(0, Math.round((nowMs - Number(ts)) / 1000));
|
|
235
|
+
if (seconds < 60) return `${seconds}s ago`;
|
|
236
|
+
const minutes = Math.round(seconds / 60);
|
|
237
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
238
|
+
const hours = Math.round(minutes / 60);
|
|
239
|
+
return `${hours}h ago`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function hasSwarmLogFields(sessions) {
|
|
243
|
+
return sessions.some(
|
|
244
|
+
(s) =>
|
|
245
|
+
s.source === "logs" ||
|
|
246
|
+
s.source === "synapse + logs" ||
|
|
247
|
+
s.runId ||
|
|
248
|
+
s.shards ||
|
|
249
|
+
s.workers,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function formatSwarmStatus(sessions) {
|
|
254
|
+
const nowMs = Date.now();
|
|
255
|
+
const rows = [];
|
|
256
|
+
rows.push(
|
|
257
|
+
"RUN_ID STATUS SHARDS WORKERS LAST_HEARTBEAT SOURCE",
|
|
258
|
+
);
|
|
259
|
+
rows.push(
|
|
260
|
+
"───────────────────── ───────── ─────── ─────── ────────────── ──────────────",
|
|
261
|
+
);
|
|
262
|
+
for (const s of sessions) {
|
|
263
|
+
const id = String(s.runId || s.sessionId || "?")
|
|
264
|
+
.padEnd(21)
|
|
265
|
+
.slice(0, 21);
|
|
266
|
+
const status = String(s.status || "active")
|
|
267
|
+
.padEnd(9)
|
|
268
|
+
.slice(0, 9);
|
|
269
|
+
const shards = String(s.shards || "-")
|
|
270
|
+
.padEnd(7)
|
|
271
|
+
.slice(0, 7);
|
|
272
|
+
const workers = String(s.workers || "-")
|
|
273
|
+
.padEnd(7)
|
|
274
|
+
.slice(0, 7);
|
|
275
|
+
const heartbeat = formatAge(s.lastHeartbeat, nowMs).padEnd(14).slice(0, 14);
|
|
276
|
+
const source = String(s.source || "synapse").slice(0, 20);
|
|
277
|
+
rows.push(
|
|
278
|
+
`${id} ${status} ${shards} ${workers} ${heartbeat} ${source}`,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
return rows.join("\n");
|
|
282
|
+
}
|
|
283
|
+
|
|
58
284
|
function formatStatus(sessions) {
|
|
59
285
|
if (!sessions.length) {
|
|
60
286
|
return "no active sessions (synapse-registry empty)";
|
|
61
287
|
}
|
|
288
|
+
if (hasSwarmLogFields(sessions)) return formatSwarmStatus(sessions);
|
|
62
289
|
const rows = [];
|
|
63
290
|
rows.push("SESSION HOST BRANCH DIRTY STATE TASK");
|
|
64
291
|
rows.push(
|
|
@@ -84,13 +311,22 @@ function formatStatus(sessions) {
|
|
|
84
311
|
export async function cmdSynapseStatus(args = [], opts = {}) {
|
|
85
312
|
const jsonOut = args.includes("--json") || opts.json;
|
|
86
313
|
const explicit = extractFlag(args, "--registry");
|
|
314
|
+
const explicitLogsDir = extractFlag(args, "--logs-dir");
|
|
87
315
|
const path = locateRegistryPath(explicit);
|
|
88
|
-
const
|
|
316
|
+
const registrySessions = loadRegistrySnapshot(path);
|
|
317
|
+
const logsDir = explicitLogsDir || DEFAULT_SWARM_LOGS_DIR;
|
|
318
|
+
const logSessions = loadSwarmLogSessions(logsDir);
|
|
319
|
+
const sessions = mergeRegistryAndLogSessions(registrySessions, logSessions);
|
|
89
320
|
|
|
90
321
|
if (jsonOut) {
|
|
91
322
|
process.stdout.write(
|
|
92
323
|
JSON.stringify(
|
|
93
|
-
{
|
|
324
|
+
{
|
|
325
|
+
registry: path,
|
|
326
|
+
logsDir: existsSync(logsDir) ? logsDir : null,
|
|
327
|
+
count: sessions.length,
|
|
328
|
+
sessions,
|
|
329
|
+
},
|
|
94
330
|
null,
|
|
95
331
|
2,
|
|
96
332
|
) + "\n",
|
|
@@ -98,13 +334,14 @@ export async function cmdSynapseStatus(args = [], opts = {}) {
|
|
|
98
334
|
return;
|
|
99
335
|
}
|
|
100
336
|
|
|
101
|
-
if (!path) {
|
|
337
|
+
if (!path && logSessions.length === 0) {
|
|
102
338
|
process.stdout.write(
|
|
103
|
-
"no registry
|
|
339
|
+
"no registry or swarm logs found (looked for .triflux/synapse-registry.json and .triflux/swarm-logs)\n",
|
|
104
340
|
);
|
|
105
341
|
return;
|
|
106
342
|
}
|
|
107
|
-
process.stdout.write(`registry: ${path}\n`);
|
|
343
|
+
if (path) process.stdout.write(`registry: ${path}\n`);
|
|
344
|
+
if (logSessions.length > 0) process.stdout.write(`swarm logs: ${logsDir}\n`);
|
|
108
345
|
process.stdout.write(`${formatStatus(sessions)}\n`);
|
|
109
346
|
}
|
|
110
347
|
|